-
Notifications
You must be signed in to change notification settings - Fork 2
feat(desktop,ipc,ui): chrome title overflow menu — thread actions, reveal, open in editor #265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AprilNEA
wants to merge
8
commits into
master
Choose a base branch
from
xuan/code-379
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
70ad77e
feat(ipc,desktop): add a shell system capability — reveal a path, det…
AprilNEA 6a17ca9
feat(desktop,ui,i18n): wire the chrome title overflow menu to thread …
AprilNEA 39ed170
test(desktop): drive the chrome title menu end to end
AprilNEA 5671ae4
feat(desktop): widen editor detection using launch-editor's catalog
AprilNEA a9f65d7
fix(desktop): resolve the editor install per launch, and require it b…
AprilNEA 41cd3e7
test(desktop): let the thread-menu e2e fail through its cleanup path
AprilNEA b118c70
refactor(ui): use session naming for title menu
lucas77778 57f24b2
fix(desktop): hoist thread menu reveal pattern
lucas77778 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| /** | ||
| * 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'; | ||
| const REVEAL_MENU_ITEM_PATTERN = /Reveal in Finder|Show in File|file manager/; | ||
|
|
||
| /** Throws rather than exiting: `main()`'s catch captures a screenshot and its finally reaps the | ||
| * daemon and the Electron app, none of which run past a `process.exit`. */ | ||
| function fail(message: string): never { | ||
| throw new Error(message); | ||
| } | ||
|
|
||
| /** | ||
| * 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<void> { | ||
| 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<void> { | ||
| 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<void> { | ||
| await win.getByRole('button', { name: 'More actions' }).click(); | ||
| await win.waitForTimeout(500); | ||
| } | ||
|
|
||
| async function run(win: Page, app: ElectronApplication, workspace: string): Promise<void> { | ||
| // 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_MENU_ITEM_PATTERN }); | ||
| 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<void> { | ||
| 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(() => '<unreadable>'); | ||
| 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(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; | ||
| import { homedir, tmpdir } from 'node:os'; | ||
| import { delimiter, join } from 'node:path'; | ||
| import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import type { EditorCandidate } from '../editors'; | ||
| import { editorTargets, isLaunchable } 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([]); | ||
| }); | ||
|
|
||
| // 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([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('isLaunchable', () => { | ||
| const dir = mkdtempSync(join(tmpdir(), 'linkcode-editors-')); | ||
|
|
||
| afterAll(() => { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| // Resolution takes the first launchable target, so a present-but-not-executable file must not | ||
| // outrank an application bundle further down the list. | ||
| it('rejects an existing file that is not executable', () => { | ||
| const file = join(dir, 'not-executable'); | ||
| writeFileSync(file, ''); | ||
| chmodSync(file, 0o644); | ||
| expect(isLaunchable({ kind: 'executable', file })).toBe(false); | ||
|
|
||
| chmodSync(file, 0o755); | ||
| expect(isLaunchable({ kind: 'executable', file })).toBe(true); | ||
| }); | ||
|
|
||
| it('rejects a missing target of either kind', () => { | ||
| const missing = join(dir, 'absent'); | ||
| expect(isLaunchable({ kind: 'executable', file: missing })).toBe(false); | ||
| expect(isLaunchable({ kind: 'mac-app', bundle: missing, label: 'X' })).toBe(false); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.