From e038766a055d8c4b3e783d1c9f98ae6118928898 Mon Sep 17 00:00:00 2001 From: adityavardhansharma Date: Fri, 13 Mar 2026 00:05:44 +0000 Subject: [PATCH] Add Open in T3 Code to Windows context menu and project launch --- apps/desktop/resources/installer.nsh | 25 +++ apps/desktop/src/main.ts | 188 +++++++++++++----- apps/desktop/src/preload.ts | 18 ++ apps/desktop/src/windowsProjectLaunch.test.ts | 44 ++++ apps/desktop/src/windowsProjectLaunch.ts | 47 +++++ apps/web/src/components/Sidebar.tsx | 54 +++++ packages/contracts/src/ipc.ts | 3 + scripts/build-desktop-artifact.ts | 3 + 8 files changed, 337 insertions(+), 45 deletions(-) create mode 100644 apps/desktop/resources/installer.nsh create mode 100644 apps/desktop/src/windowsProjectLaunch.test.ts create mode 100644 apps/desktop/src/windowsProjectLaunch.ts diff --git a/apps/desktop/resources/installer.nsh b/apps/desktop/resources/installer.nsh new file mode 100644 index 000000000..a59947523 --- /dev/null +++ b/apps/desktop/resources/installer.nsh @@ -0,0 +1,25 @@ +!define T3CODE_DIRECTORY_SHELL_KEY "Software\Classes\Directory\shell\T3Code.OpenInT3Code" +!define T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY "Software\Classes\Directory\Background\shell\T3Code.OpenInT3Code" + +!macro WriteT3CodeContextMenuEntries + WriteRegStr HKCU "${T3CODE_DIRECTORY_SHELL_KEY}" "" "Open in T3 Code" + WriteRegStr HKCU "${T3CODE_DIRECTORY_SHELL_KEY}" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + WriteRegStr HKCU "${T3CODE_DIRECTORY_SHELL_KEY}\command" "" "$\"$INSTDIR\${APP_EXECUTABLE_FILENAME}$\" $\"%1$\"" + + WriteRegStr HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}" "" "Open in T3 Code" + WriteRegStr HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + WriteRegStr HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}\command" "" "$\"$INSTDIR\${APP_EXECUTABLE_FILENAME}$\" $\"%V$\"" +!macroend + +!macro DeleteT3CodeContextMenuEntries + DeleteRegKey HKCU "${T3CODE_DIRECTORY_SHELL_KEY}" + DeleteRegKey HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}" +!macroend + +!macro customInstall + !insertmacro WriteT3CodeContextMenuEntries +!macroend + +!macro customUnInstall + !insertmacro DeleteT3CodeContextMenuEntries +!macroend diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1631046a6..2c853188b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -30,6 +30,7 @@ import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; +import { extractProjectLaunchPathFromArgv } from "./windowsProjectLaunch"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -49,6 +50,10 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL = + "desktop:mark-open-project-path-listener-ready"; +const GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL = "desktop:get-pending-open-project-paths"; +const OPEN_PROJECT_PATH_CHANNEL = "desktop:open-project-path"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -75,6 +80,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; +const SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH = process.platform === "win32"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; @@ -91,6 +97,8 @@ let aboutCommitHashCache: string | null | undefined; let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; +let openProjectPathListenerReady = false; +const pendingOpenProjectPaths: string[] = []; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ @@ -100,6 +108,15 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ }); const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); +const hasSingleInstanceLock = + !SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH || app.requestSingleInstanceLock(); + +if (SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH) { + const initialProjectLaunchPath = extractProjectLaunchPathFromArgv(process.argv); + if (initialProjectLaunchPath) { + pendingOpenProjectPaths.push(initialProjectLaunchPath); + } +} function logTimestamp(): string { return new Date().toISOString(); @@ -1050,6 +1067,62 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { }); } +function queueOpenProjectPath(path: string): void { + if (pendingOpenProjectPaths.includes(path)) { + return; + } + pendingOpenProjectPaths.push(path); +} + +function takePendingOpenProjectPaths(): string[] { + const paths = [...pendingOpenProjectPaths]; + pendingOpenProjectPaths.length = 0; + return paths; +} + +function revealMainWindow(): void { + const window = mainWindow; + if (!window || window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + if (!window.isVisible()) { + window.show(); + } + window.focus(); +} + +function dispatchOpenProjectPath(path: string): void { + const window = mainWindow; + if ( + !window || + window.isDestroyed() || + !openProjectPathListenerReady || + window.webContents.isLoadingMainFrame() + ) { + queueOpenProjectPath(path); + return; + } + + window.webContents.send(OPEN_PROJECT_PATH_CHANNEL, path); +} + +function handleOpenProjectPath(path: string): void { + if (!path) { + return; + } + + if ((!mainWindow || mainWindow.isDestroyed()) && app.isReady()) { + mainWindow = createWindow(); + } + + revealMainWindow(); + dispatchOpenProjectPath(path); +} + function registerIpcHandlers(): void { ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { @@ -1085,6 +1158,14 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); + ipcMain.removeHandler(MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL); + ipcMain.handle(MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL, async () => { + openProjectPathListenerReady = true; + }); + + ipcMain.removeHandler(GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL); + ipcMain.handle(GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL, async () => takePendingOpenProjectPaths()); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, @@ -1200,6 +1281,8 @@ function getIconOption(): { icon: string } | Record { } function createWindow(): BrowserWindow { + openProjectPathListenerReady = false; + const window = new BrowserWindow({ width: 1100, height: 780, @@ -1277,6 +1360,7 @@ function createWindow(): BrowserWindow { window.on("closed", () => { if (mainWindow === window) { mainWindow = null; + openProjectPathListenerReady = false; } }); @@ -1311,60 +1395,74 @@ async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap main window created"); } -app.on("before-quit", () => { - isQuitting = true; - writeDesktopLogHeader("before-quit received"); - clearUpdatePollTimer(); - stopBackend(); - restoreStdIoCapture?.(); -}); - -app - .whenReady() - .then(() => { - writeDesktopLogHeader("app ready"); - configureAppIdentity(); - configureApplicationMenu(); - registerDesktopProtocol(); - configureAutoUpdater(); - void bootstrap().catch((error) => { - handleFatalStartupError("bootstrap", error); - }); - - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createWindow(); +if (!hasSingleInstanceLock) { + app.quit(); +} else { + if (SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH) { + app.on("second-instance", (_event, argv) => { + revealMainWindow(); + const projectLaunchPath = extractProjectLaunchPathFromArgv(argv); + if (projectLaunchPath) { + handleOpenProjectPath(projectLaunchPath); } }); - }) - .catch((error) => { - handleFatalStartupError("whenReady", error); - }); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); } -}); -if (process.platform !== "win32") { - process.on("SIGINT", () => { - if (isQuitting) return; + app.on("before-quit", () => { isQuitting = true; - writeDesktopLogHeader("SIGINT received"); + writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); stopBackend(); restoreStdIoCapture?.(); - app.quit(); }); - process.on("SIGTERM", () => { - if (isQuitting) return; - isQuitting = true; - writeDesktopLogHeader("SIGTERM received"); - clearUpdatePollTimer(); - stopBackend(); - restoreStdIoCapture?.(); - app.quit(); + app + .whenReady() + .then(() => { + writeDesktopLogHeader("app ready"); + configureAppIdentity(); + configureApplicationMenu(); + registerDesktopProtocol(); + configureAutoUpdater(); + void bootstrap().catch((error) => { + handleFatalStartupError("bootstrap", error); + }); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + mainWindow = createWindow(); + } + }); + }) + .catch((error) => { + handleFatalStartupError("whenReady", error); + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } }); + + if (process.platform !== "win32") { + process.on("SIGINT", () => { + if (isQuitting) return; + isQuitting = true; + writeDesktopLogHeader("SIGINT received"); + clearUpdatePollTimer(); + stopBackend(); + restoreStdIoCapture?.(); + app.quit(); + }); + + process.on("SIGTERM", () => { + if (isQuitting) return; + isQuitting = true; + writeDesktopLogHeader("SIGTERM received"); + clearUpdatePollTimer(); + stopBackend(); + restoreStdIoCapture?.(); + app.quit(); + }); + } } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..7eece74ed 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,10 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL = + "desktop:mark-open-project-path-listener-ready"; +const GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL = "desktop:get-pending-open-project-paths"; +const OPEN_PROJECT_PATH_CHANNEL = "desktop:open-project-path"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -18,6 +22,20 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + markOpenProjectPathListenerReady: () => + ipcRenderer.invoke(MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL), + getPendingOpenProjectPaths: () => ipcRenderer.invoke(GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL), + onOpenProjectPath: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, path: unknown) => { + if (typeof path !== "string") return; + listener(path); + }; + + ipcRenderer.on(OPEN_PROJECT_PATH_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(OPEN_PROJECT_PATH_CHANNEL, wrappedListener); + }; + }, showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/desktop/src/windowsProjectLaunch.test.ts b/apps/desktop/src/windowsProjectLaunch.test.ts new file mode 100644 index 000000000..8bffe8bba --- /dev/null +++ b/apps/desktop/src/windowsProjectLaunch.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { extractProjectLaunchPathFromArgv } from "./windowsProjectLaunch"; + +describe("extractProjectLaunchPathFromArgv", () => { + it("returns the first absolute directory argument", () => { + const path = extractProjectLaunchPathFromArgv( + [ + "C:\\Program Files\\T3 Code\\T3 Code.exe", + "--inspect=0", + "C:\\Users\\alice\\src\\demo", + "C:\\Users\\alice\\src\\other", + ], + (candidate) => ({ + isDirectory: () => candidate === "C:\\Users\\alice\\src\\demo", + }), + ); + + expect(path).toBe("C:\\Users\\alice\\src\\demo"); + }); + + it("ignores files, relative paths, and flags", () => { + const path = extractProjectLaunchPathFromArgv( + [ + "main.js", + "--t3code-dev-root=C:\\repo\\apps\\desktop", + "C:\\Users\\alice\\src\\demo\\README.md", + ], + () => ({ + isDirectory: () => false, + }), + ); + + expect(path).toBeNull(); + }); + + it("accepts quoted explorer command arguments", () => { + const path = extractProjectLaunchPathFromArgv(['"C:\\Users\\alice\\src\\quoted"'], () => ({ + isDirectory: () => true, + })); + + expect(path).toBe("C:\\Users\\alice\\src\\quoted"); + }); +}); diff --git a/apps/desktop/src/windowsProjectLaunch.ts b/apps/desktop/src/windowsProjectLaunch.ts new file mode 100644 index 000000000..d9ecfaaf1 --- /dev/null +++ b/apps/desktop/src/windowsProjectLaunch.ts @@ -0,0 +1,47 @@ +import * as FS from "node:fs"; +import { win32 as WindowsPath } from "node:path"; + +type DirectoryStat = Pick; + +function stripWrappingQuotes(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1); + } + return value; +} + +function normalizeProjectLaunchCandidate(value: string): string | null { + const trimmed = stripWrappingQuotes(value.trim()); + if (trimmed.length === 0 || trimmed.startsWith("-")) { + return null; + } + + const normalized = WindowsPath.normalize(trimmed); + if (!WindowsPath.isAbsolute(normalized)) { + return null; + } + + return normalized; +} + +export function extractProjectLaunchPathFromArgv( + argv: readonly string[], + statSync: (path: string) => DirectoryStat = FS.statSync, +): string | null { + for (const value of argv) { + const candidate = normalizeProjectLaunchCandidate(value); + if (!candidate) { + continue; + } + + try { + if (statSync(candidate).isDirectory()) { + return candidate; + } + } catch { + // Ignore non-existent or inaccessible paths while scanning argv. + } + } + + return null; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ffd6de92..12fb45ea7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -294,6 +294,8 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); + const addProjectFromPathRef = useRef<(cwd: string) => Promise>(async () => undefined); + const desktopOpenProjectQueueRef = useRef(Promise.resolve()); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< @@ -467,6 +469,16 @@ export default function Sidebar() { ], ); + useEffect(() => { + addProjectFromPathRef.current = addProjectFromPath; + }, [addProjectFromPath]); + + const enqueueDesktopOpenProjectPath = useCallback((cwd: string) => { + desktopOpenProjectQueueRef.current = desktopOpenProjectQueueRef.current + .then(() => addProjectFromPathRef.current(cwd)) + .catch(() => undefined); + }, []); + const handleAddProject = () => { void addProjectFromPath(newCwd); }; @@ -962,6 +974,48 @@ export default function Sidebar() { }; }, [clearSelection, selectedThreadIds.size]); + useEffect(() => { + if (!isElectron) return; + const bridge = window.desktopBridge; + if ( + !bridge || + typeof bridge.markOpenProjectPathListenerReady !== "function" || + typeof bridge.getPendingOpenProjectPaths !== "function" || + typeof bridge.onOpenProjectPath !== "function" + ) { + return; + } + + let disposed = false; + const handleOpenProjectPath = (path: string) => { + const normalizedPath = path.trim(); + if (disposed || normalizedPath.length === 0) { + return; + } + enqueueDesktopOpenProjectPath(normalizedPath); + }; + + const unsubscribe = bridge.onOpenProjectPath(handleOpenProjectPath); + + void bridge + .markOpenProjectPathListenerReady() + .then(() => bridge.getPendingOpenProjectPaths()) + .then((paths) => { + if (disposed) return; + for (const path of paths) { + if (typeof path === "string") { + handleOpenProjectPath(path); + } + } + }) + .catch(() => undefined); + + return () => { + disposed = true; + unsubscribe(); + }; + }, [enqueueDesktopOpenProjectPath]); + useEffect(() => { if (!isElectron) return; const bridge = window.desktopBridge; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..e647b5669 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -99,6 +99,9 @@ export interface DesktopBridge { pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; + markOpenProjectPathListenerReady: () => Promise; + getPendingOpenProjectPaths: () => Promise; + onOpenProjectPath: (listener: (path: string) => void) => () => void; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 0b875721f..1c1bb424c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -486,6 +486,9 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; } buildConfig.win = winConfig; + buildConfig.nsis = { + include: "installer.nsh", + }; } return buildConfig;