Skip to content

Commit 0e51f1e

Browse files
authored
Merge pull request #114 from Friedrich482/features
Websocket connection between dashboard and extension and extension ux improvements and fixes
2 parents f6398be + 6c9650b commit 0e51f1e

25 files changed

Lines changed: 574 additions & 229 deletions

apps/dashboard/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1111
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
1212
import { createTRPCClient, httpBatchLink } from "@trpc/client";
1313

14+
import useExtensionWebsocket from "./hooks/useExtensionWebsocket";
1415
import { TRPCProvider } from "./utils/trpc";
1516

1617
function makeQueryClient() {
@@ -85,6 +86,8 @@ function App() {
8586
}),
8687
);
8788

89+
useExtensionWebsocket();
90+
8891
return (
8992
<ThemeProvider>
9093
<QueryClientProvider client={queryClient}>

apps/dashboard/src/components/project-page/files-list/LanguagesDropDown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const LanguagesDropDown = ({
4242
key={entry.languageSlug}
4343
onCheckedChange={() => handleCheckEntry(entry)}
4444
onSelect={(e) => e.preventDefault()}
45-
className="cursor-pointer gap-3 rounded-md px-3 py-1 text-base"
45+
className="cursor-pointer gap-3 rounded-md py-1 text-base"
4646
>
4747
<span
4848
className="size-4 shrink-0 rounded-full"
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useEffect, useRef } from "react";
2+
3+
import { DASHBOARD_DEVELOPMENT_WS_PORT } from "@repo/common/constants";
4+
import { WsData, WsDataSchema } from "@repo/common/types-schemas";
5+
6+
export const initializeWebSocket = () => {
7+
const isDev = import.meta.env.DEV;
8+
let wsUrl: string;
9+
10+
if (isDev) {
11+
wsUrl = `ws://localhost:${DASHBOARD_DEVELOPMENT_WS_PORT}`;
12+
} else {
13+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
14+
wsUrl = `${protocol}//${window.location.host}`;
15+
}
16+
17+
const ws = new WebSocket(wsUrl);
18+
19+
ws.onopen = () => {
20+
ws.send(JSON.stringify({ type: "ready" } satisfies WsData));
21+
};
22+
23+
ws.onmessage = async (event) => {
24+
try {
25+
const data = JSON.parse(event.data);
26+
const validated = WsDataSchema.safeParse(data);
27+
if (!validated.success) {
28+
console.error("Invalid message shape");
29+
return;
30+
}
31+
32+
const { type } = validated.data;
33+
34+
if (type === "navigate") {
35+
const { path } = validated.data;
36+
37+
window.history.pushState({}, "", path);
38+
window.dispatchEvent(new PopStateEvent("popstate"));
39+
40+
if (window.Notification && Notification.permission === "granted") {
41+
const notification = new Notification("Dashboard", {
42+
body: "Click to go to the dashboard",
43+
icon: "/moon.svg",
44+
requireInteraction: false,
45+
});
46+
47+
notification.onclick = () => {
48+
window.focus();
49+
notification.close();
50+
};
51+
52+
setTimeout(() => notification.close(), 3000);
53+
} else if (
54+
window.Notification &&
55+
Notification.permission === "default"
56+
) {
57+
Notification.requestPermission();
58+
} else {
59+
window.focus();
60+
}
61+
62+
ws.send(JSON.stringify({ type: "navigated", path } satisfies WsData));
63+
}
64+
} catch (error) {
65+
console.error("Failed to handle WebSocket message:", error);
66+
}
67+
};
68+
69+
ws.onerror = (error) => {
70+
console.error("WebSocket error:", error);
71+
};
72+
73+
return ws;
74+
};
75+
76+
const useExtensionWebsocket = () => {
77+
const wsRef = useRef<WebSocket>(null);
78+
79+
useEffect(() => {
80+
wsRef.current = initializeWebSocket();
81+
82+
return () => {
83+
if (wsRef.current) {
84+
if (wsRef.current.readyState === WebSocket.OPEN) {
85+
wsRef.current.send(
86+
JSON.stringify({ type: "closed" } satisfies WsData),
87+
);
88+
}
89+
wsRef.current.close();
90+
}
91+
};
92+
}, []);
93+
};
94+
95+
export default useExtensionWebsocket;

apps/vscode-extension/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "mooncode",
33
"displayName": "MoonCode",
44
"description": "MoonCode is an extension that tracks your coding time (like WakaTime) and gives you a detailed summary about all your coding statistics. With MoonCode, developers get the full history of their coding activity.",
5-
"version": "0.0.45",
5+
"version": "0.0.46",
66
"icon": "./public/moon.png",
77
"publisher": "Friedrich482",
88
"author": {
@@ -127,6 +127,7 @@
127127
"@types/mocha": "^10.0.9",
128128
"@types/node": "20.x",
129129
"@types/vscode": "^1.95.0",
130+
"@types/ws": "^8.18.1",
130131
"@typescript-eslint/eslint-plugin": "^8.23.0",
131132
"@typescript-eslint/parser": "^8.23.0",
132133
"@vscode/test-cli": "^0.0.10",
@@ -145,6 +146,7 @@
145146
"dotenv": "^16.0.3",
146147
"express": "^4.21.2",
147148
"get-port": "^7.1.0",
149+
"ws": "^8.19.0",
148150
"zod": "^4.0.17"
149151
}
150152
}

apps/vscode-extension/src/extension.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import vscode from "vscode";
22

33
import calculateTime from "@/utils/time/calculateTime";
44

5+
import { DashboardServer } from "./types-schemas";
6+
import { getLoginContext } from "./utils/auth/loginContext";
57
import registerAuthUriHandler from "./utils/auth/registerAuthUriHandler";
68
import initExtensionCommands from "./utils/commands/initExtensionCommands";
7-
import serveDashboard from "./utils/dashboard/serveDashboard";
9+
import serveDashboard from "./utils/dashboard/serve-dashboard/serveDashboard";
810
import setEnvironmentContext from "./utils/env/setEnvironmentContext";
911
import fetchInitialData from "./utils/fetchInitialData";
1012
import initializeFiles from "./utils/files/initializeFiles";
@@ -14,21 +16,24 @@ import addStatusBarItem from "./utils/status-bar/addStatusBarItem";
1416
import setStatusBarItem from "./utils/status-bar/setStatusBarItem";
1517

1618
let extensionContext: vscode.ExtensionContext;
17-
let dashboardPort: number | undefined;
19+
let dashboardServer: DashboardServer | undefined;
1820
let statusBarItem: vscode.StatusBarItem;
1921

2022
export async function activate(context: vscode.ExtensionContext) {
2123
extensionContext = context;
2224

2325
setEnvironmentContext();
24-
dashboardPort = await serveDashboard(context);
26+
dashboardServer = await serveDashboard(context);
2527
registerAuthUriHandler();
2628

2729
statusBarItem = addStatusBarItem();
2830

2931
const { timeSpent, initialFilesData } = await fetchInitialData();
3032

31-
setStatusBarItem({ type: "time", timeSpentToday: timeSpent });
33+
const isLoggedIn = getLoginContext();
34+
if (isLoggedIn) {
35+
setStatusBarItem({ type: "time", timeSpentToday: timeSpent });
36+
}
3237

3338
initializeFiles(initialFilesData);
3439

@@ -58,13 +63,13 @@ export const getExtensionContext = () => {
5863
return extensionContext;
5964
};
6065

61-
export const getDashboardPort = () => {
62-
if (!dashboardPort) {
66+
export const getDashboardServer = () => {
67+
if (!dashboardServer) {
6368
throw new Error(
64-
"Failed to start the extension. Dashboard could not be served."
69+
"Failed to start the extension. Dashboard could not be served.",
6570
);
6671
}
67-
return dashboardPort;
72+
return dashboardServer;
6873
};
6974

7075
export const getStatusBarItem = () => {

apps/vscode-extension/src/types-schemas.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,23 @@ export const globalStateInitialDataSchema = z.object({
4343
languageSlug: z.string().min(1),
4444
projectName: z.string().min(1),
4545
fileName: z.string().min(1),
46-
})
46+
}),
4747
),
4848

4949
updatedAt: z.union([
5050
z.date(),
5151
z.iso.datetime().transform((str) => new Date(str)),
5252
]),
53-
})
53+
}),
5454
),
5555
});
5656
export type FileMap = Record<string, FileData>;
5757
export type GlobalStateData = z.infer<typeof globalStateInitialDataSchema>;
5858
export type FileDataSync = GlobalStateData["dailyData"][string]["dayFilesData"];
59+
60+
export type DashboardServer = {
61+
port: number;
62+
navigate: (path: string) => void;
63+
isWindowOpen: () => boolean;
64+
close: () => void;
65+
};
Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,30 @@
1-
import * as vscode from "vscode";
2-
31
import { getExtensionContext } from "@/extension";
42

5-
import setStatusBarAfterLogin from "../status-bar/setStatusBarAfterLogin";
6-
import setStatusBarItem from "../status-bar/setStatusBarItem";
7-
import login from "./login";
3+
import setLoginContextAndStatusBar from "../status-bar/setLoginContextAndStatusBar";
4+
import setLogoutContextAndStatusBar from "../status-bar/setLogoutContextAndStatusBar";
85
import parseJwtPayload from "./parseJwtPayload";
9-
import setLoginContext from "./setLoginContext";
106

117
const getToken = async () => {
128
const context = getExtensionContext();
139

14-
let token = await context.secrets.get("authToken");
10+
const token = await context.secrets.get("authToken");
1511

1612
const parsedPayload = parseJwtPayload(token);
1713

1814
if (!parsedPayload.success) {
19-
await setLoginContext(false);
20-
setStatusBarItem({ type: "auth" });
21-
22-
await login();
23-
token = await context.secrets.get("authToken");
15+
await setLogoutContextAndStatusBar();
2416
return token;
2517
}
2618

2719
const { exp: expireDate } = parsedPayload.data;
2820

2921
if (!token || expireDate * 1000 < Date.now()) {
30-
await setLoginContext(false);
31-
setStatusBarItem({ type: "auth" });
32-
33-
const selection = await vscode.window.showInformationMessage(
34-
"You are logged out. Please login",
35-
"Login"
36-
);
37-
38-
if (selection !== "Login") {
39-
return undefined;
40-
}
41-
42-
await login();
43-
token = await context.secrets.get("authToken");
22+
await setLogoutContextAndStatusBar();
23+
return token;
4424
}
4525

46-
await setLoginContext(true);
47-
await setStatusBarAfterLogin();
48-
26+
await setLoginContextAndStatusBar();
4927
return token;
5028
};
29+
5130
export default getToken;

apps/vscode-extension/src/utils/auth/login.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import crypto from "crypto";
22
import * as vscode from "vscode";
33

4-
import { getDashboardPort, getExtensionContext } from "@/extension";
4+
import { getDashboardServer, getExtensionContext } from "@/extension";
55

66
const login = async () => {
77
const context = getExtensionContext();
8-
const dashboardPort = getDashboardPort();
8+
const dashboardServer = getDashboardServer();
99

1010
try {
1111
let state = await context.secrets.get("authState");
@@ -19,29 +19,27 @@ const login = async () => {
1919

2020
const callbackUri = await vscode.env.asExternalUri(
2121
vscode.Uri.parse(
22-
`vscode://${publisher}.${extensionId}/auth-callback?state=${state}`
23-
)
22+
`vscode://${publisher}.${extensionId}/auth-callback?state=${state}`,
23+
),
2424
);
2525

26-
const dashboardLoginUrl = vscode.Uri.parse(
27-
`http://localhost:${dashboardPort}/login?client=vscode&callback=${encodeURIComponent(callbackUri.toString())}`
28-
);
26+
const dashboardLoginPath = `/login?client=vscode&callback=${encodeURIComponent(callbackUri.toString())}`;
2927

3028
const selection = await vscode.window.showInformationMessage(
3129
"Open the local dashboard to login",
32-
"Open Dashboard"
30+
"Open Dashboard",
3331
);
3432

3533
if (!selection) {
3634
return;
3735
}
3836

3937
if (selection === "Open Dashboard") {
40-
vscode.env.openExternal(dashboardLoginUrl);
38+
dashboardServer.navigate(dashboardLoginPath);
4139
}
4240
} catch (error) {
4341
vscode.window.showErrorMessage(
44-
`An error occurred: ${error instanceof Error ? error.message : error}`
42+
`An error occurred: ${error instanceof Error ? error.message : error}`,
4543
);
4644
}
4745
};

apps/vscode-extension/src/utils/auth/setLoginContext.ts renamed to apps/vscode-extension/src/utils/auth/loginContext.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import vscode from "vscode";
22

3+
let isLoggedIn = false;
4+
35
const setLoginContext = async (state: boolean) => {
6+
isLoggedIn = state;
47
await vscode.commands.executeCommand(
58
"setContext",
69
"MoonCode.isLoggedIn",
710
state,
811
);
912
};
1013

11-
export default setLoginContext;
14+
const getLoginContext = () => {
15+
return isLoggedIn;
16+
};
17+
18+
export { getLoginContext, setLoginContext };

0 commit comments

Comments
 (0)