From 70ad77ef037461bf8af61f40ee45270d1dd5c208 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Wed, 22 Jul 2026 01:25:43 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(ipc,desktop):=20add=20a=20shell=20syst?= =?UTF-8?q?em=20capability=20=E2=80=94=20reveal=20a=20path,=20detect=20and?= =?UTF-8?q?=20launch=20editors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/__tests__/editors.test.ts | 62 +++++++ apps/desktop/src/main/editors.ts | 158 ++++++++++++++++++ apps/desktop/src/main/system-context.ts | 8 +- apps/desktop/src/renderer/src/ipc.ts | 7 + packages/system-plane/ipc/src/bridge.ts | 9 + packages/system-plane/ipc/src/context.ts | 25 +++ .../system-plane/ipc/src/electron-main.ts | 6 + .../system-plane/ipc/src/electron-renderer.ts | 5 + packages/system-plane/ipc/src/events.ts | 7 + 9 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/main/__tests__/editors.test.ts create mode 100644 apps/desktop/src/main/editors.ts diff --git a/apps/desktop/src/main/__tests__/editors.test.ts b/apps/desktop/src/main/__tests__/editors.test.ts new file mode 100644 index 000000000..0f657086e --- /dev/null +++ b/apps/desktop/src/main/__tests__/editors.test.ts @@ -0,0 +1,62 @@ +import { homedir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { EditorCandidate } from '../editors'; +import { editorTargets } from '../editors'; + +const CURSOR: EditorCandidate = { + id: 'cursor', + label: 'Cursor', + cli: 'cursor', + macApp: 'Cursor.app', + windowsExe: join('cursor', 'Cursor.exe'), +}; + +describe('editorTargets', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('probes the CLI ahead of the app bundle on macOS', () => { + vi.stubEnv('PATH', '/usr/local/bin'); + const targets = editorTargets(CURSOR, 'darwin'); + + expect(targets[0]).toEqual({ kind: 'executable', file: '/usr/local/bin/cursor' }); + expect(targets).toContainEqual({ + kind: 'mac-app', + bundle: '/Applications/Cursor.app', + label: 'Cursor', + }); + expect(targets).toContainEqual({ + kind: 'mac-app', + bundle: join(homedir(), 'Applications', 'Cursor.app'), + label: 'Cursor', + }); + }); + + it('offers no app bundle off macOS', () => { + vi.stubEnv('PATH', '/usr/bin'); + expect(editorTargets(CURSOR, 'linux').every((target) => target.kind === 'executable')).toBe( + true, + ); + }); + + // The Windows editor CLIs are `.cmd` shims that spawn cannot exec without a shell, so Windows + // resolves through installed executables only. + it('skips the PATH scan on Windows and probes the program roots', () => { + const localAppData = String.raw`C:\Users\dev\AppData\Local`; + const programFiles = String.raw`C:\Program Files`; + vi.stubEnv('PATH', [String.raw`C:\bin`, String.raw`C:\other`].join(delimiter)); + vi.stubEnv('LOCALAPPDATA', localAppData); + vi.stubEnv('ProgramFiles', programFiles); + + expect(editorTargets(CURSOR, 'win32')).toEqual([ + { kind: 'executable', file: join(localAppData, 'Programs', 'cursor', 'Cursor.exe') }, + { kind: 'executable', file: join(programFiles, 'cursor', 'Cursor.exe') }, + ]); + }); + + it('yields nothing for an editor with no target on the platform', () => { + expect(editorTargets({ id: 'x', label: 'X' }, 'darwin')).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/editors.ts b/apps/desktop/src/main/editors.ts new file mode 100644 index 000000000..30061d93c --- /dev/null +++ b/apps/desktop/src/main/editors.ts @@ -0,0 +1,158 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import { executableSearchLocations } from '@linkcode/common/node'; +import type { DetectedEditor } from '@linkcode/ipc'; +import { nullthrow } from 'foxact/nullthrow'; + +/** + * Detects the external editors installed on this machine and opens a workspace in one, backing + * the chrome title menu's "open in editor" item (CODE-379). + */ + +/** One editor we know how to find and launch. Absent fields simply yield no candidate path. */ +export interface EditorCandidate { + id: string; + label: string; + /** + * CLI base name, probed on PATH and the shared fallback install locations. POSIX only: the + * Windows editor CLIs are `.cmd` shims, which `spawn` cannot exec without a shell. + */ + cli?: string; + /** Application-bundle directory name under `/Applications` or `~/Applications`. */ + macApp?: string; + /** Executable path relative to a Windows program root. */ + windowsExe?: string; +} + +/** A resolved, launchable editor install. */ +type EditorTarget = + | { kind: 'executable'; file: string } + | { kind: 'mac-app'; bundle: string; label: string }; + +const EDITOR_CANDIDATES: EditorCandidate[] = [ + { + id: 'vscode', + label: 'Visual Studio Code', + cli: 'code', + macApp: 'Visual Studio Code.app', + windowsExe: join('Microsoft VS Code', 'Code.exe'), + }, + { + id: 'vscode-insiders', + label: 'Visual Studio Code - Insiders', + cli: 'code-insiders', + macApp: 'Visual Studio Code - Insiders.app', + windowsExe: join('Microsoft VS Code Insiders', 'Code - Insiders.exe'), + }, + { + id: 'cursor', + label: 'Cursor', + cli: 'cursor', + macApp: 'Cursor.app', + windowsExe: join('cursor', 'Cursor.exe'), + }, + { + id: 'windsurf', + label: 'Windsurf', + cli: 'windsurf', + macApp: 'Windsurf.app', + windowsExe: join('Windsurf', 'Windsurf.exe'), + }, + { id: 'zed', label: 'Zed', cli: 'zed', macApp: 'Zed.app', windowsExe: join('Zed', 'zed.exe') }, + { + id: 'sublime-text', + label: 'Sublime Text', + cli: 'subl', + macApp: 'Sublime Text.app', + windowsExe: join('Sublime Text', 'sublime_text.exe'), + }, +]; + +/** Roots Windows installers target, in the order they are probed. */ +function windowsProgramRoots(): string[] { + const roots = [ + process.env.LOCALAPPDATA === undefined ? undefined : join(process.env.LOCALAPPDATA, 'Programs'), + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + ]; + return roots.filter((root) => root !== undefined); +} + +/** + * Every place `candidate` may be installed on `platform`, in precedence order. On macOS the app + * bundle is probed alongside the CLI because the `code`-style shim is opt-in — plenty of installs + * have the editor but not the command. + */ +export function editorTargets( + candidate: EditorCandidate, + platform: NodeJS.Platform, +): EditorTarget[] { + const targets: EditorTarget[] = []; + if (platform !== 'win32' && candidate.cli !== undefined) { + for (const file of executableSearchLocations(candidate.cli)) { + targets.push({ kind: 'executable', file }); + } + } + if (platform === 'darwin' && candidate.macApp !== undefined) { + for (const root of ['/Applications', join(homedir(), 'Applications')]) { + targets.push({ + kind: 'mac-app', + bundle: join(root, candidate.macApp), + label: candidate.label, + }); + } + } + if (platform === 'win32' && candidate.windowsExe !== undefined) { + for (const root of windowsProgramRoots()) { + targets.push({ kind: 'executable', file: join(root, candidate.windowsExe) }); + } + } + return targets; +} + +function targetPath(target: EditorTarget): string { + return target.kind === 'mac-app' ? target.bundle : target.file; +} + +// Probed once per process: an editor installed mid-session appears after a restart. +let installs: Map | undefined; + +function detectInstalls(): Map { + installs ??= new Map( + EDITOR_CANDIDATES.flatMap((candidate) => { + const target = editorTargets(candidate, process.platform).find((each) => + existsSync(targetPath(each)), + ); + return target === undefined ? [] : [[candidate.id, target] as const]; + }), + ); + return installs; +} + +export function listEditors(): DetectedEditor[] { + const found = detectInstalls(); + return EDITOR_CANDIDATES.flatMap(({ id, label }) => (found.has(id) ? [{ id, label }] : [])); +} + +export function openInEditor(editorId: string, path: string): Promise { + const target = nullthrow(detectInstalls().get(editorId), `unknown editor: ${editorId}`); + + const [file, args] = + target.kind === 'mac-app' + ? ['/usr/bin/open', ['-a', target.bundle, path]] + : [target.file, [path]]; + + return new Promise((resolve, reject) => { + // Detached with ignored stdio: the editor outlives this app, and nobody drains its pipes. + // windowsHide keeps a console-subsystem launcher from flashing a window on packaged Windows. + const child = spawn(file, args, { detached: true, stdio: 'ignore', windowsHide: true }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +} diff --git a/apps/desktop/src/main/system-context.ts b/apps/desktop/src/main/system-context.ts index 7f3069477..b8ea9e70d 100644 --- a/apps/desktop/src/main/system-context.ts +++ b/apps/desktop/src/main/system-context.ts @@ -1,11 +1,12 @@ import type { SystemContext } from '@linkcode/ipc'; import { NOTIFICATION_CLICKED_CHANNEL } from '@linkcode/ipc'; import type { BrowserWindow } from 'electron'; -import { app, dialog, Notification } from 'electron'; +import { app, dialog, Notification, shell } from 'electron'; import { applyThemePreference } from './appearance'; import { resolveDaemonUrl } from './daemon-discovery'; import { isDaemonManaged, retryDaemonSupervisor } from './daemon-supervisor'; import { ensureDefaultPickerDirectory } from './default-picker-directory'; +import { listEditors, openInEditor } from './editors'; import { getSettings, setSettings } from './settings'; import { checkForUpdates } from './updater'; @@ -36,6 +37,11 @@ export function systemContextFor(win: BrowserWindow): SystemContext { return result.filePaths; }, }, + shell: { + revealPath: (path) => shell.showItemInFolder(path), + listEditors: () => listEditors(), + openInEditor: ({ editorId, path }) => openInEditor(editorId, path), + }, app: { getVersion: () => app.getVersion(), checkForUpdates: () => checkForUpdates(), diff --git a/apps/desktop/src/renderer/src/ipc.ts b/apps/desktop/src/renderer/src/ipc.ts index bc224e663..afbdfb239 100644 --- a/apps/desktop/src/renderer/src/ipc.ts +++ b/apps/desktop/src/renderer/src/ipc.ts @@ -29,6 +29,13 @@ export const systemBridge: SystemBridge = { fs: { pickFile: (opts) => traceRendererIpc('fs.pick-file', () => source.fs.pickFile(opts)), }, + shell: { + revealPath: (path) => + traceRendererIpc('shell.reveal-path', () => source.shell.revealPath(path)), + listEditors: () => traceRendererIpc('shell.list-editors', () => source.shell.listEditors()), + openInEditor: (editorId, path) => + traceRendererIpc('shell.open-in-editor', () => source.shell.openInEditor(editorId, path)), + }, app: { version: () => traceRendererIpc('app.version', () => source.app.version()), platform: source.app.platform, diff --git a/packages/system-plane/ipc/src/bridge.ts b/packages/system-plane/ipc/src/bridge.ts index d6f8f1bcc..f0f1da433 100644 --- a/packages/system-plane/ipc/src/bridge.ts +++ b/packages/system-plane/ipc/src/bridge.ts @@ -1,6 +1,7 @@ import type { DesktopSettings, DesktopSettingsPatch, + DetectedEditor, PickFileOptions, SystemNotification, UpdaterStatus, @@ -23,6 +24,14 @@ export interface SystemBridge { * one-element array. */ pickFile(opts?: PickFileOptions): Promise; }; + shell: { + /** Reveal a path in the OS file manager (Finder / Explorer / …). */ + revealPath(path: string): Promise; + /** External editors detected on this machine, in display order; empty when none is installed. */ + listEditors(): Promise; + /** Launch a detected editor (its opaque `id` from `listEditors`) on `path`. */ + openInEditor(editorId: string, path: string): Promise; + }; app: { version(): Promise; /** Synchronous Electron platform supplied by the sandboxed preload. */ diff --git a/packages/system-plane/ipc/src/context.ts b/packages/system-plane/ipc/src/context.ts index 8e8983de4..7010d265d 100644 --- a/packages/system-plane/ipc/src/context.ts +++ b/packages/system-plane/ipc/src/context.ts @@ -17,6 +17,14 @@ export interface SystemContext { * one-element array. */ pickFile(opts?: PickFileOptions): Promise; }; + shell: { + /** Reveal a path in the OS file manager (Finder / Explorer / …). */ + revealPath(path: string): void; + /** External editors detected on this machine, in display order; empty when none is installed. */ + listEditors(): DetectedEditor[]; + /** Launch a detected editor on `path`; an unknown `editorId` rejects. */ + openInEditor(request: OpenInEditorRequest): Promise; + }; app: { getVersion(): string; /** Trigger a manual update check (no-op when the app is not packaged). */ @@ -57,6 +65,23 @@ export const PickFileOptionsSchema = z.object({ }); export type PickFileOptions = z.infer; +/** A filesystem path handed to a shell operation. */ +export const ShellPathSchema = z.string().min(1); + +/** One external editor found on this machine. `id` is opaque to the renderer — it comes back + * verbatim on launch, so the renderer never learns a binary path or a bundle location. */ +export const DetectedEditorSchema = z.object({ + id: z.string(), + label: z.string(), +}); +export type DetectedEditor = z.infer; + +export const OpenInEditorRequestSchema = z.object({ + editorId: z.string(), + path: ShellPathSchema, +}); +export type OpenInEditorRequest = z.infer; + /** Display parameters for one OS notification — a native-UI operation, not a data channel. The * renderer decides whether/what to notify; `clickToken` is opaque to this layer and echoed back * verbatim on click so the renderer can route. */ diff --git a/packages/system-plane/ipc/src/electron-main.ts b/packages/system-plane/ipc/src/electron-main.ts index 4d19f6162..6d8a6ed0b 100644 --- a/packages/system-plane/ipc/src/electron-main.ts +++ b/packages/system-plane/ipc/src/electron-main.ts @@ -4,7 +4,9 @@ import type { BrowserWindow, IpcMain, IpcMainEvent } from 'electron'; import type { SystemContext } from './context'; import { DesktopSettingsPatchSchema, + OpenInEditorRequestSchema, PickFileOptionsSchema, + ShellPathSchema, SystemNotificationSchema, } from './context'; import { @@ -41,6 +43,10 @@ export function bindElectronSystemIpc({ }, windowIsMaximized: () => ctx.window.isMaximized(), fsPickFile: (opts) => ctx.dialog.pickFile(PickFileOptionsSchema.optional().parse(opts)), + shellRevealPath: (path) => ctx.shell.revealPath(ShellPathSchema.parse(path)), + shellListEditors: () => ctx.shell.listEditors(), + shellOpenInEditor: (request) => + ctx.shell.openInEditor(OpenInEditorRequestSchema.parse(request)), appVersion: () => ctx.app.getVersion(), appCheckForUpdates: () => ctx.app.checkForUpdates(), daemonIsManaged: () => ctx.daemon.isManaged(), diff --git a/packages/system-plane/ipc/src/electron-renderer.ts b/packages/system-plane/ipc/src/electron-renderer.ts index e32e22c20..a4c11be06 100644 --- a/packages/system-plane/ipc/src/electron-renderer.ts +++ b/packages/system-plane/ipc/src/electron-renderer.ts @@ -51,6 +51,11 @@ export function createElectronSystemBridge( fs: { pickFile: (opts) => invoke.fsPickFile(opts), }, + shell: { + revealPath: (path) => invoke.shellRevealPath(path), + listEditors: () => invoke.shellListEditors(), + openInEditor: (editorId, path) => invoke.shellOpenInEditor({ editorId, path }), + }, app: { version: () => invoke.appVersion(), platform, diff --git a/packages/system-plane/ipc/src/events.ts b/packages/system-plane/ipc/src/events.ts index c688e9b8c..6dd3ec494 100644 --- a/packages/system-plane/ipc/src/events.ts +++ b/packages/system-plane/ipc/src/events.ts @@ -2,6 +2,8 @@ import { defineInvokeEventa } from '@moeru/eventa'; import type { DesktopSettings, DesktopSettingsPatch, + DetectedEditor, + OpenInEditorRequest, PickFileOptions, SystemNotification, } from './context'; @@ -28,6 +30,11 @@ export const systemIpcEvents = { fsPickFile: defineInvokeEventa( 'linkcode.system.fs.pickFile', ), + shellRevealPath: defineInvokeEventa('linkcode.system.shell.revealPath'), + shellListEditors: defineInvokeEventa('linkcode.system.shell.listEditors'), + shellOpenInEditor: defineInvokeEventa( + 'linkcode.system.shell.openInEditor', + ), appVersion: defineInvokeEventa('linkcode.system.app.version'), appCheckForUpdates: defineInvokeEventa('linkcode.system.app.checkForUpdates'), daemonIsManaged: defineInvokeEventa('linkcode.system.daemon.isManaged'), From 6a17ca99add69e8b25a43aa09d57a037148a96bf Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Wed, 22 Jul 2026 01:33:28 +0800 Subject: [PATCH 2/8] feat(desktop,ui,i18n): wire the chrome title overflow menu to thread actions --- .../src/renderer/src/shell/chrome/chrome.tsx | 15 +- .../src/renderer/src/shell/desktop-shell.tsx | 41 ++++- packages/presentation/i18n/src/locales/en.ts | 13 ++ .../presentation/i18n/src/locales/zh-cn.ts | 13 ++ packages/presentation/ui/src/shell/index.ts | 1 + .../ui/src/shell/thread-title-menu.tsx | 143 ++++++++++++++++++ 6 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 packages/presentation/ui/src/shell/thread-title-menu.tsx diff --git a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx index fd742bdc2..ca20e8cc5 100644 --- a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx +++ b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx @@ -5,7 +5,6 @@ import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; import { ChevronLeftIcon, ChevronRightIcon, - EllipsisIcon, FileTextIcon, PanelBottomIcon, PanelLeftIcon, @@ -51,6 +50,8 @@ export interface DesktopChromeProps { titleIcon?: React.ReactNode; /** Rendered beside the default title (ignored when `titleContent` overrides the whole area). */ titleChip?: React.ReactNode; + /** The title area's trailing overflow menu (ignored when `titleContent` overrides the area). */ + titleMenu?: React.ReactNode; } export type DesktopChromeSegment = 'sidebar' | 'main' | 'right'; @@ -174,6 +175,7 @@ export function DesktopChrome({ titleContent, titleIcon, titleChip, + titleMenu, }: DesktopChromeProps): React.ReactNode { const [portalTargets, setPortalTargets] = useState({}); const [portalUse, setPortalUse] = useState({}); @@ -219,6 +221,7 @@ export function DesktopChrome({ titleContent={titleContent} titleIcon={titleIcon} titleChip={titleChip} + titleMenu={titleMenu} portalUse={portalUse} setPortalTarget={setPortalTarget} /> @@ -270,6 +273,7 @@ function ChromeSegmentGrid({ titleContent, titleIcon, titleChip, + titleMenu, portalUse, setPortalTarget, }: { @@ -279,6 +283,7 @@ function ChromeSegmentGrid({ titleContent?: React.ReactNode; titleIcon?: React.ReactNode; titleChip?: React.ReactNode; + titleMenu?: React.ReactNode; portalUse: ChromePortalUseMap; setPortalTarget: SetChromePortalTarget; }): React.ReactNode { @@ -309,7 +314,7 @@ function ChromeSegmentGrid({ // and controls, so the document title/actions step aside entirely. defaultSlots={{ left: expandedPanel ? null : titleContent === undefined ? ( - + ) : ( titleContent ), @@ -512,10 +517,12 @@ function MainChromeTitle({ header, icon, chip, + menu, }: { header: WorkbenchShellHeader; icon?: React.ReactNode; chip?: React.ReactNode; + menu?: React.ReactNode; }): React.ReactNode { return (
@@ -524,9 +531,7 @@ function MainChromeTitle({ {header.title} {chip} - - - + {menu}
); } diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 356ee1485..4f330525f 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -1,5 +1,5 @@ import type { SystemBridge, ThemePreference } from '@linkcode/ipc'; -import type { ComposerAttachment } from '@linkcode/ui'; +import type { ComposerAttachment, FileManagerKind, ThreadTitleMenuEditor } from '@linkcode/ui'; import { AgentIcon, ConversationSurface, @@ -8,6 +8,7 @@ import { HostFooter, NewSessionSurface, SessionSidebar, + ThreadTitleMenu, useKeyboardShortcutLabel, } from '@linkcode/ui'; import { @@ -164,6 +165,7 @@ export function DesktopShell({ ); const desktopPlatform = systemBridge.app.platform; const [appVersion, setAppVersion] = useState(''); + const [editors, setEditors] = useState([]); const sidebarShortcut = useKeyboardShortcutLabel('desktop.toggle-sidebar'); const bottomPanelShortcut = useKeyboardShortcutLabel('desktop.toggle-bottom-panel'); const rightPanelShortcut = useKeyboardShortcutLabel('desktop.toggle-right-panel'); @@ -237,6 +239,16 @@ export function DesktopShell({ [systemBridge], ); + // Probed once per window: main caches the detection for its whole process lifetime anyway. + useEffect( + (signal) => { + void systemBridge.shell.listEditors().then((value) => { + if (!signal.aborted) setEditors(value); + }); + }, + [systemBridge], + ); + const active = activeSession; const titledSession = active?.title === undefined ? null : active; const hideMainTitle = draft !== null || (active === null ? false : titledSession === null); @@ -597,6 +609,27 @@ export function DesktopShell({ /> } + titleMenu={ + titledSession ? ( + onToggleSessionPinned(titledSession.sessionId)} + // Both reject on a real failure (a missing path, a launch that never spawned); + // nothing here can recover, so the rejection surfaces through error reporting. + onReveal={() => { + void systemBridge.shell.revealPath(titledSession.cwd); + }} + onOpenInEditor={(editorId) => { + void systemBridge.shell.openInEditor(editorId, titledSession.cwd); + }} + onClose={() => onCloseSession(titledSession.sessionId)} + /> + ) : undefined + } onShowSidebar={() => updateSidebarOpen(true)} onHideSidebar={() => updateSidebarOpen(false)} onToggleRight={() => togglePanel('right')} @@ -675,6 +708,12 @@ export function DesktopShell({ ); } +/** The system plane's platform, narrowed to the file manager the reveal item should name. */ +function fileManagerKind(platform: NodeJS.Platform): FileManagerKind { + if (platform === 'darwin' || platform === 'win32') return platform; + return 'other'; +} + function createPanelContentHost(): HTMLDivElement { const host = document.createElement('div'); host.className = 'absolute inset-0'; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index b0ff4fdd2..55a89b3c2 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -504,6 +504,19 @@ export const en = { groupFallback: 'Group {id}', }, }, + threadMenu: { + label: 'More actions', + pin: 'Pin thread', + unpin: 'Unpin thread', + copyTitle: 'Copy title', + reveal: { + darwin: 'Reveal in Finder', + win32: 'Show in File Explorer', + other: 'Show in file manager', + }, + openInEditor: 'Open in editor', + close: 'Close thread', + }, palette: { placeholder: 'Search threads or run a command', recentGroup: 'Recent', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index fbcca4f77..dbafde64e 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -492,6 +492,19 @@ export const zhCN = { groupFallback: '群组 {id}', }, }, + threadMenu: { + label: '更多操作', + pin: '置顶线程', + unpin: '取消置顶', + copyTitle: '复制标题', + reveal: { + darwin: '在访达中显示', + win32: '在文件资源管理器中显示', + other: '在文件管理器中显示', + }, + openInEditor: '用编辑器打开', + close: '关闭线程', + }, palette: { placeholder: '搜索线程或运行命令', recentGroup: '最近', diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index 80c73c092..c3c4a88d6 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -27,4 +27,5 @@ export * from './shell-sidebar'; export * from './sidebar'; export * from './terminal/prefs'; export * from './terminal-settings-panel'; +export * from './thread-title-menu'; export * from './use-relative-time-label'; diff --git a/packages/presentation/ui/src/shell/thread-title-menu.tsx b/packages/presentation/ui/src/shell/thread-title-menu.tsx new file mode 100644 index 000000000..36659f8e1 --- /dev/null +++ b/packages/presentation/ui/src/shell/thread-title-menu.tsx @@ -0,0 +1,143 @@ +import { + Menu, + MenuGroup, + MenuItem, + MenuPopup, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from 'coss-ui/components/menu'; +import { useCopyToClipboard } from 'coss-ui/hooks/use-copy-to-clipboard'; +import { + CheckIcon, + CopyIcon, + EllipsisIcon, + FolderOpenIcon, + PinIcon, + PinOffIcon, + SquarePenIcon, + XIcon, +} from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import { ShellIconButton } from './shell-control'; + +/** Which file manager the reveal item names — the host maps its platform onto this. */ +export type FileManagerKind = 'darwin' | 'win32' | 'other'; + +/** One editor the host detected; `id` is opaque and travels back on open. */ +export interface ThreadTitleMenuEditor { + id: string; + label: string; +} + +export interface ThreadTitleMenuProps { + /** Copied verbatim by the copy item. */ + title: string; + pinned: boolean; + fileManager: FileManagerKind; + /** Detected editors; empty hides the open-in-editor item entirely. */ + editors: ThreadTitleMenuEditor[]; + onTogglePin: () => void; + onReveal: () => void; + onOpenInEditor: (editorId: string) => void; + /** Stop the thread if live and drop it from the list. */ + onClose: () => void; +} + +const REVEAL_KEY = { + darwin: 'reveal.darwin', + win32: 'reveal.win32', + other: 'reveal.other', +} as const; + +/** The chrome title's overflow menu: what you can do to the thread you're looking at. */ +export function ThreadTitleMenu({ + title, + pinned, + fileManager, + editors, + onTogglePin, + onReveal, + onOpenInEditor, + onClose, +}: ThreadTitleMenuProps): React.ReactNode { + const t = useTranslations('workbench.threadMenu'); + const { copyToClipboard, isCopied } = useCopyToClipboard(); + + return ( + + + + + } + /> + + + + {pinned ? : } + {pinned ? t('unpin') : t('pin')} + + copyToClipboard(title)}> + {isCopied ? : } + {t('copyTitle')} + + + + + + + {t(REVEAL_KEY[fileManager])} + + + + + + + {t('close')} + + + + ); +} + +/** Nothing detected hides the item, one install runs directly, several open a chooser. */ +function EditorItem({ + editors, + label, + onOpen, +}: { + editors: ThreadTitleMenuEditor[]; + label: string; + onOpen: (editorId: string) => void; +}): React.ReactNode { + const only = editors.length === 1 ? editors.at(0) : undefined; + if (only !== undefined) { + return ( + onOpen(only.id)}> + + {label} + + ); + } + if (editors.length === 0) return null; + + return ( + + + + {label} + + + {editors.map((editor) => ( + onOpen(editor.id)}> + {editor.label} + + ))} + + + ); +} From 39ed1701d41552ff6fbe26fdcf8be6ed1caca196 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Wed, 22 Jul 2026 01:58:34 +0800 Subject: [PATCH 3/8] test(desktop): drive the chrome title menu end to end --- apps/desktop/e2e/thread-menu.e2e.mts | 252 +++++++++++++++++++++++++++ apps/desktop/package.json | 1 + 2 files changed, 253 insertions(+) create mode 100644 apps/desktop/e2e/thread-menu.e2e.mts diff --git a/apps/desktop/e2e/thread-menu.e2e.mts b/apps/desktop/e2e/thread-menu.e2e.mts new file mode 100644 index 000000000..fb5615fd3 --- /dev/null +++ b/apps/desktop/e2e/thread-menu.e2e.mts @@ -0,0 +1,252 @@ +/** + * Chrome title overflow-menu E2E (CODE-379): boots an isolated daemon + the built desktop app with a + * pre-seeded titled session, then drives the title menu — pin round-trips through the sidebar, copy + * lands on the clipboard, reveal reaches the main process with the thread's cwd, and close drops the + * thread. `shell.showItemInFolder` is swapped out in main so the run never pops a Finder window. + * + * Deliberately NOT covered: clicking "open in editor" would launch a real editor over the + * developer's desktop, so this only asserts the item reflects what main detected — the launch path + * itself is unit-tested (`src/main/__tests__/editors.test.ts`) and verified by hand. + * + * Run `pnpm -F @linkcode/desktop e2e:thread-menu` after building daemon and desktop. Needs no agent + * CLI: the session is seeded straight into the daemon's database and never run. + */ + +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { noop } from 'foxts/noop'; +import { wait } from 'foxts/wait'; +import type { ElectronApplication, Page } from 'playwright-core'; +import { _electron } from 'playwright-core'; + +const require = createRequire(import.meta.url); +const desktopDir = resolve(import.meta.dirname, '..'); +const daemonDir = resolve(desktopDir, '../daemon'); +const electronBinary = require('electron') as unknown as string; + +const PORT = 44000 + (process.pid % 1000); +const SESSION_ID = 'e2e-thread-menu-session'; +const SESSION_TITLE = 'Seeded thread for the title menu'; + +function fail(message: string): never { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +/** + * Ready means the runtime file is on disk, not merely that something answers on the port: the + * desktop discovers its endpoint through `$HOME/.linkcode/runtime.json`, and a restart that races + * the previous daemon's shutdown leaves the port answering with no runtime file to find. + */ +async function waitForDaemon(home: string): Promise { + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (existsSync(join(home, '.linkcode', 'runtime.json'))) { + try { + await fetch(`http://127.0.0.1:${PORT}/socket.io/?EIO=4&transport=polling`); + return; + } catch { + /* not accepting connections yet */ + } + } + await wait(250); + } + fail(`daemon did not come up on port ${PORT}`); +} + +function startDaemon(home: string): ChildProcess { + return spawn(process.execPath, ['dist/index.js'], { + cwd: daemonDir, + env: { ...process.env, HOME: home, LINKCODE_PORT: String(PORT) }, + stdio: 'ignore', + }); +} + +function stopDaemon(daemon: ChildProcess): Promise { + return new Promise((resolve) => { + daemon.once('exit', () => resolve()); + daemon.kill('SIGTERM'); + }); +} + +/** Insert one titled, never-run session so the chrome renders its title area (and the menu). */ +function seedSession(home: string, cwd: string): void { + const Database = require('better-sqlite3') as new ( + path: string, + ) => { + prepare(sql: string): { run(...params: unknown[]): void }; + close(): void; + }; + const db = new Database(join(home, '.linkcode', 'daemon.db')); + const now = Date.now(); + db.prepare( + `INSERT INTO sessions + (session_id, kind, cwd, title, origin_type, created_at, updated_at) + VALUES (?, 'pi', ?, ?, 'created', ?, ?)`, + ).run(SESSION_ID, cwd, SESSION_TITLE, now, now); + db.close(); +} + +async function openMenu(win: Page): Promise { + await win.getByRole('button', { name: 'More actions' }).click(); + await win.waitForTimeout(500); +} + +async function run(win: Page, app: ElectronApplication, workspace: string): Promise { + // No composer to wait on: under a throwaway HOME no agent is signed in, so the surface shows + // its onboarding card. The seeded thread row is the real readiness signal. + const thread = win.getByRole('button', { name: new RegExp(SESSION_TITLE) }).first(); + await thread.waitFor({ state: 'visible', timeout: 30000 }); + await thread.click(); + await win.waitForTimeout(1500); + + await win.getByRole('button', { name: 'More actions' }).waitFor({ + state: 'visible', + timeout: 15000, + }); + console.log('title menu trigger rendered for the seeded thread'); + + // Pin: the item flips to Unpin and the thread joins the sidebar's Pinned group. + await openMenu(win); + const openShot = join(tmpdir(), `linkcode-e2e-thread-menu-open-${process.pid}.png`); + await win.screenshot({ path: openShot }); + console.log(`open-menu screenshot: ${openShot}`); + await win.getByRole('menuitem', { name: 'Pin thread' }).click(); + await win.waitForTimeout(1000); + if (!(await win.evaluate(() => document.body.innerText.includes('Pinned')))) { + fail('pinning from the title menu did not add a Pinned group to the sidebar'); + } + await openMenu(win); + const unpin = win.getByRole('menuitem', { name: 'Unpin thread' }); + if ((await unpin.count()) === 0) fail('the menu did not flip to Unpin after pinning'); + await unpin.click(); + await win.waitForTimeout(1000); + console.log('pin/unpin round-trips through the sidebar'); + + // Copy: read the clipboard from main, where no page-permission prompt applies. + await app.evaluate(({ clipboard }) => clipboard.writeText('e2e-clipboard-sentinel')); + await openMenu(win); + await win.getByRole('menuitem', { name: 'Copy title' }).click(); + await win.waitForTimeout(1000); + const clipboard = await app.evaluate(({ clipboard: c }) => c.readText()); + if (clipboard !== SESSION_TITLE) { + fail(`clipboard holds ${JSON.stringify(clipboard)}, expected the thread title`); + } + console.log('copy title lands on the clipboard'); + + // Reveal: the swapped-in main handler records the path instead of opening a file manager. + await openMenu(win); + const reveal = win.getByRole('menuitem', { name: /Reveal in Finder|Show in File|file manager/ }); + if ((await reveal.count()) === 0) fail('the menu has no reveal item'); + await reveal.click(); + await win.waitForTimeout(1000); + const revealed = await app.evaluate( + () => (globalThis as unknown as { __e2eRevealed?: string[] }).__e2eRevealed ?? [], + ); + if (revealed.length !== 1) fail(`reveal reached main ${revealed.length} times, expected 1`); + if (revealed[0] !== workspace) { + fail(`reveal got ${JSON.stringify(revealed[0])}, expected the thread cwd ${workspace}`); + } + console.log(`reveal reached main with the thread cwd (${revealed[0]})`); + + // Open in editor: presence only — clicking would launch a real editor. Whether the item is a + // plain entry, a chooser submenu, or absent depends on what main detected on this host. + await openMenu(win); + const editorItem = win.getByRole('menuitem', { name: 'Open in editor' }); + console.log( + (await editorItem.count()) === 0 + ? 'no editor detected on this host; the item is correctly hidden' + : 'open-in-editor item present', + ); + await win.keyboard.press('Escape'); + await win.waitForTimeout(500); + + // Close: the thread leaves the list, taking the title area (and this menu) with it. + await openMenu(win); + await win.getByRole('menuitem', { name: 'Close thread' }).click(); + await win.waitForTimeout(2000); + if (await win.evaluate((title) => document.body.innerText.includes(title), SESSION_TITLE)) { + fail('closing from the title menu left the thread in the list'); + } + console.log('close removes the thread'); + + const shot = join(tmpdir(), `linkcode-e2e-thread-menu-${process.pid}.png`); + await win.screenshot({ path: shot }); + console.log(`final screenshot: ${shot}`); +} + +async function main(): Promise { + if (!existsSync(join(daemonDir, 'dist/index.js'))) { + fail('apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first'); + } + if (!existsSync(join(desktopDir, 'out/main/index.js'))) { + fail('apps/desktop/out is missing — run `pnpm -F @linkcode/desktop build` first'); + } + + const home = mkdtempSync(join(tmpdir(), 'linkcode-e2e-home-')); + const userData = mkdtempSync(join(tmpdir(), 'linkcode-e2e-userdata-')); + const workspace = join(home, 'LinkCode'); + mkdirSync(workspace, { recursive: true }); + + let daemon: ChildProcess | null = null; + let app: ElectronApplication | null = null; + let passed = false; + try { + // Boot once so the daemon creates and migrates the database, seed, then restart it so the + // session list is served from the seeded rows. + daemon = startDaemon(home); + await waitForDaemon(home); + await stopDaemon(daemon); + seedSession(home, workspace); + daemon = startDaemon(home); + await waitForDaemon(home); + console.log(`daemon up on :${PORT} with a seeded titled session (HOME=${home})`); + + app = await _electron.launch({ + executablePath: electronBinary, + args: [desktopDir, `--user-data-dir=${userData}`, '--use-mock-keychain'], + env: { ...process.env, HOME: home }, + }); + + // The system context calls `shell.showItemInFolder` as a property lookup, so replacing it on + // the electron module records the call without opening a file-manager window. + await app.evaluate(({ shell }) => { + const recorder = globalThis as unknown as { __e2eRevealed?: string[] }; + recorder.__e2eRevealed = []; + shell.showItemInFolder = (fullPath: string): void => { + recorder.__e2eRevealed?.push(fullPath); + }; + }); + + const win = await app.firstWindow(); + win.on('pageerror', (error) => console.error(`[renderer:error] ${error.message}`)); + try { + await run(win, app, workspace); + } catch (error) { + const shot = join(tmpdir(), `linkcode-e2e-thread-menu-${process.pid}.png`); + await win.screenshot({ path: shot }).catch(noop); + const body = await win.evaluate(() => document.body.innerText).catch(() => ''); + console.error(`screenshot: ${shot}`); + console.error(`body text:\n${body.slice(0, 2000)}`); + throw error; + } + passed = true; + console.log('PASS'); + } finally { + await app?.close().catch(noop); + daemon?.kill('SIGTERM'); + if (passed) { + rmSync(home, { recursive: true, force: true }); + rmSync(userData, { recursive: true, force: true }); + } else { + console.error(`kept for debugging: HOME=${home} userData=${userData}`); + process.exitCode = 1; + } + } +} + +void main(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7f382333f..a34857fb6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "e2e:notifications": "node e2e/notifications.e2e.mts", "e2e:file-tree": "node e2e/file-tree.e2e.mts", "e2e:simulator": "node e2e/simulator-panel.e2e.mts", + "e2e:thread-menu": "node e2e/thread-menu.e2e.mts", "e2e:window-bounds": "node e2e/window-bounds.e2e.mts", "lint": "pnpm --dir ../.. exec eslint --format=sukka apps/desktop" }, From 5671ae476d3fe4ed169299f864c02caa294d07bd Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 24 Jul 2026 01:09:16 +0800 Subject: [PATCH 4/8] feat(desktop): widen editor detection using launch-editor's catalog Adds VSCodium, Trae, Antigravity, and the common JetBrains IDEs. JetBrains carry no windowsExe: their installer nests the binary under a version-stamped directory this model can't address, so Windows stays uncovered rather than guessed. --- .../src/main/__tests__/editors.test.ts | 13 ++++++ apps/desktop/src/main/editors.ts | 45 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/editors.test.ts b/apps/desktop/src/main/__tests__/editors.test.ts index 0f657086e..c414893f7 100644 --- a/apps/desktop/src/main/__tests__/editors.test.ts +++ b/apps/desktop/src/main/__tests__/editors.test.ts @@ -59,4 +59,17 @@ describe('editorTargets', () => { it('yields nothing for an editor with no target on the platform', () => { expect(editorTargets({ id: 'x', label: 'X' }, 'darwin')).toEqual([]); }); + + // JetBrains entries carry no windowsExe by design, so Windows detection is a no-op for them. + it('yields nothing on Windows for a cli-and-bundle-only editor', () => { + vi.stubEnv('PATH', String.raw`C:\bin`); + vi.stubEnv('LOCALAPPDATA', String.raw`C:\Users\dev\AppData\Local`); + const jetBrainsShaped = { + id: 'webstorm', + label: 'WebStorm', + cli: 'webstorm', + macApp: 'WebStorm.app', + }; + expect(editorTargets(jetBrainsShaped, 'win32')).toEqual([]); + }); }); diff --git a/apps/desktop/src/main/editors.ts b/apps/desktop/src/main/editors.ts index 30061d93c..ec9fab18e 100644 --- a/apps/desktop/src/main/editors.ts +++ b/apps/desktop/src/main/editors.ts @@ -32,6 +32,20 @@ type EditorTarget = | { kind: 'executable'; file: string } | { kind: 'mac-app'; bundle: string; label: string }; +/** + * A JetBrains IDE. All open a directory as a project; their bundle is `