From ce297dcbcafe44732965d83c8be6261b0e424a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 5 Aug 2026 16:23:07 +0200 Subject: [PATCH 1/2] feat(desktop): open workspaces in native apps Expose project-folder, terminal, and explicit editor actions through the command palette and native File menus. Add context actions to Files rows while preserving the existing internal navigation behavior and copy-path fallback for web and remote windows. Resolve workspace and worktree roots through the authenticated local server in Electron and Tauri. Canonicalize selected paths, reject workspace escapes and unsafe executable opening, and launch platform tools without interpolating paths into a general-purpose shell. Cover path confinement, Windows open modes, editor selection, and terminal launch behavior. Validate with the full Electron and Tauri test suites, typechecks, and the Electron production build. --- packages/electron-app/electron/main/ipc.ts | 75 ++- packages/electron-app/electron/main/menu.ts | 50 +- .../electron/main/workspace-open.test.ts | 80 +++ .../electron/main/workspace-open.ts | 238 +++++++ .../electron-app/electron/preload/index.cjs | 7 + packages/electron-app/package.json | 2 +- packages/tauri-app/src-tauri/src/main.rs | 150 ++++- .../tauri-app/src-tauri/src/workspace_open.rs | 632 ++++++++++++++++++ packages/ui/src/App.tsx | 26 +- .../shell/right-panel/tabs/FilesTab.tsx | 139 +++- .../shell/right-panel/tabs/files-runtime.tsx | 2 + packages/ui/src/lib/hooks/use-commands.ts | 70 ++ .../ui/src/lib/i18n/messages/de/commands.ts | 13 + .../ui/src/lib/i18n/messages/de/instance.ts | 9 + .../ui/src/lib/i18n/messages/en/commands.ts | 13 + .../ui/src/lib/i18n/messages/en/instance.ts | 9 + .../ui/src/lib/i18n/messages/es/commands.ts | 13 + .../ui/src/lib/i18n/messages/es/instance.ts | 9 + .../ui/src/lib/i18n/messages/fr/commands.ts | 13 + .../ui/src/lib/i18n/messages/fr/instance.ts | 9 + .../ui/src/lib/i18n/messages/he/commands.ts | 13 + .../ui/src/lib/i18n/messages/he/instance.ts | 9 + .../ui/src/lib/i18n/messages/ja/commands.ts | 13 + .../ui/src/lib/i18n/messages/ja/instance.ts | 9 + .../ui/src/lib/i18n/messages/ne/commands.ts | 13 + .../ui/src/lib/i18n/messages/ne/instance.ts | 9 + .../ui/src/lib/i18n/messages/ru/commands.ts | 13 + .../ui/src/lib/i18n/messages/ru/instance.ts | 9 + .../src/lib/i18n/messages/zh-Hans/commands.ts | 13 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 9 + packages/ui/src/lib/workspace-open.ts | 40 ++ packages/ui/src/styles/panels/right-panel.css | 4 + packages/ui/src/types/global.d.ts | 9 + 33 files changed, 1698 insertions(+), 24 deletions(-) create mode 100644 packages/electron-app/electron/main/workspace-open.test.ts create mode 100644 packages/electron-app/electron/main/workspace-open.ts create mode 100644 packages/tauri-app/src-tauri/src/workspace_open.rs create mode 100644 packages/ui/src/lib/workspace-open.ts diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 6e664d907..661ad9ce8 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,7 +1,9 @@ -import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" +import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron" import fs from "fs" import { requestMicrophoneAccess } from "./permissions" import type { CliProcessManager, CliStatus } from "./process-manager" +import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open" +import { setWorkspaceMenuEnabled } from "./menu" let wakeLockId: number | null = null @@ -18,6 +20,34 @@ interface DialogOpenResult { paths: string[] } +async function resolveLocalWorkspaceFolder( + mainWindow: BrowserWindow, + cliManager: CliProcessManager, + instanceId: string, + worktreeSlug: string, +): Promise { + const baseUrl = cliManager.getStatus().url + if (!baseUrl) throw new Error("Local CodeNomad server is unavailable") + const cookieName = cliManager.getAuthCookieName() + const cookie = (await mainWindow.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0] + const headers = cookie ? { Cookie: `${cookie.name}=${cookie.value}` } : undefined + const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, { headers }) + if (!workspaceResponse.ok) throw new Error("Workspace is not active") + const workspace = await workspaceResponse.json() as { path?: unknown } + if (typeof workspace.path !== "string") throw new Error("Workspace path is unavailable") + if (worktreeSlug === "root") return workspace.path + + const worktreeResponse = await fetch( + `${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`, + { headers }, + ) + if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable") + const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> } + const worktree = payload.worktrees?.find((candidate) => candidate.slug === worktreeSlug) + if (!worktree || typeof worktree.directory !== "string") throw new Error("Selected worktree is unavailable") + return worktree.directory +} + export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { cliManager.on("status", (status: CliStatus) => { if (!mainWindow.isDestroyed()) { @@ -88,6 +118,49 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return directories }) + ipcMain.handle( + "workspace:openTarget", + async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => { + if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) { + throw new Error("Workspace open requests are limited to the local main window") + } + const localUrl = cliManager.getStatus().url + if (!localUrl || new URL(event.senderFrame.url).origin !== new URL(localUrl).origin) { + throw new Error("Workspace open requests require the local CodeNomad origin") + } + const target = payload?.target + const instanceId = payload?.instanceId + const worktreeSlug = payload?.worktreeSlug + const editor = payload?.editor + if ( + (target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor") + || typeof instanceId !== "string" + || typeof worktreeSlug !== "string" + || (payload.path !== undefined && typeof payload.path !== "string") + || (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium") + ) { + throw new Error("Invalid workspace open request") + } + const workspaceFolder = await resolveLocalWorkspaceFolder(mainWindow, cliManager, instanceId, worktreeSlug) + await openWorkspaceTarget( + target as WorkspaceOpenTarget, + workspaceFolder, + payload.path as string | undefined, + editor as WorkspaceEditor | undefined, + { openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path) }, + ) + return { ok: true } + }, + ) + + ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => { + if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) { + throw new Error("Workspace menu updates are limited to the local main window") + } + setWorkspaceMenuEnabled(enabled === true) + return { ok: true } + }) + ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { const next = Boolean(enabled) if (next) { diff --git a/packages/electron-app/electron/main/menu.ts b/packages/electron-app/electron/main/menu.ts index 7ecbc195c..ae345ba44 100644 --- a/packages/electron-app/electron/main/menu.ts +++ b/packages/electron-app/electron/main/menu.ts @@ -1,12 +1,34 @@ -import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" +import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron" interface ApplicationMenuActions { reload(): void forceReload(): void } +let workspaceActionsRequested = false +let applicationMenu: Menu | null = null +let localMainWindow: BrowserWindow | null = null + +function updateWorkspaceMenuState() { + const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow + for (const id of ["open-workspace-folder", "open-workspace-terminal", "open-workspace-editor"]) { + const item = applicationMenu?.getMenuItemById(id) + if (item) item.enabled = enabled + } +} + +export function setWorkspaceMenuEnabled(enabled: boolean) { + workspaceActionsRequested = enabled + updateWorkspaceMenuState() +} + export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) { + localMainWindow = mainWindow const isMac = process.platform === "darwin" + const sendCommand = (id: string) => () => { + if (id.startsWith("open-workspace-") && BrowserWindow.getFocusedWindow() !== mainWindow) return + mainWindow.webContents.send("menu:action", id) + } const template: MenuItemConstructorOptions[] = [ ...(isMac @@ -31,9 +53,20 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic { label: "New Instance", accelerator: "CmdOrCtrl+N", - click: () => { - mainWindow.webContents.send("menu:newInstance") - }, + click: sendCommand("new-instance"), + }, + { type: "separator" as const }, + { id: "open-workspace-folder", label: "Open Project Folder", click: sendCommand("open-workspace-folder") }, + { id: "open-workspace-terminal", label: "Open Terminal Here", click: sendCommand("open-workspace-terminal") }, + { + id: "open-workspace-editor", + label: "Open Project In", + submenu: [ + { label: "VS Code", click: sendCommand("open-workspace-editor-vscode") }, + { label: "Cursor", click: sendCommand("open-workspace-editor-cursor") }, + { label: "Zed", click: sendCommand("open-workspace-editor-zed") }, + { label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") }, + ], }, { type: "separator" as const }, isMac ? { role: "close" as const } : { role: "quit" as const }, @@ -85,5 +118,14 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic ] const menu = Menu.buildFromTemplate(template) + applicationMenu = menu Menu.setApplicationMenu(menu) + updateWorkspaceMenuState() + mainWindow.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { + if (!isMainFrame) return + workspaceActionsRequested = false + updateWorkspaceMenuState() + }) + app.on("browser-window-focus", updateWorkspaceMenuState) + app.on("browser-window-blur", updateWorkspaceMenuState) } diff --git a/packages/electron-app/electron/main/workspace-open.test.ts b/packages/electron-app/electron/main/workspace-open.test.ts new file mode 100644 index 000000000..a59b824d4 --- /dev/null +++ b/packages/electron-app/electron/main/workspace-open.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict" +import { EventEmitter } from "node:events" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" +import type { ChildProcess } from "node:child_process" +import { defaultOpenMode, editorCandidates, openWorkspaceTarget } from "./workspace-open" + +test("selects only the requested editor", () => { + assert.deepEqual(editorCandidates("zed", "linux", {}), [{ command: "zed", verifyStart: true }]) + assert.deepEqual(editorCandidates("vscodium", "darwin", {}), [ + { command: "/usr/bin/open", args: ["-a", "VSCodium"], waitForExit: true }, + ]) +}) + +test("opens only paths inside the workspace", async () => { + const root = mkdtempSync(join(tmpdir(), "codenomad-workspace-")) + const outside = mkdtempSync(join(tmpdir(), "codenomad-outside-")) + const opened: string[] = [] + const dependencies = { + openPath: async (path: string) => { opened.push(path); return "" }, + revealPath: () => undefined, + } + + try { + await openWorkspaceTarget("default", root, ".", undefined, dependencies) + assert.deepEqual(opened, [root]) + await assert.rejects(openWorkspaceTarget("default", root, outside, undefined, dependencies), /outside the workspace/) + } finally { + rmSync(root, { recursive: true, force: true }) + rmSync(outside, { recursive: true, force: true }) + } +}) + +test("edits Windows scripts and rejects executable default-open targets", () => { + const root = mkdtempSync(join(tmpdir(), "codenomad-executable-")) + const script = join(root, "run.cmd") + writeFileSync(script, "echo unsafe") + try { + assert.equal(defaultOpenMode(script, "win32"), "edit") + const document = join(root, "notes.txt") + writeFileSync(document, "safe") + assert.equal(defaultOpenMode(document, "win32"), "open") + const executable = join(root, "run.exe") + writeFileSync(executable, "unsafe") + assert.equal(defaultOpenMode(executable, "win32"), "choose") + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test("launches terminals in the selected directory without a shell", async () => { + const root = mkdtempSync(join(tmpdir(), "codenomad-terminal-")) + const nested = join(root, "nested") + mkdirSync(nested) + const launches: Array<{ command: string; args: readonly string[]; cwd: string }> = [] + const spawnProcess = (command: string, args: readonly string[], options: { cwd: string }) => { + launches.push({ command, args, cwd: options.cwd }) + const child = new EventEmitter() as ChildProcess + child.unref = () => child + queueMicrotask(() => { + child.emit("spawn") + child.emit("exit", 0) + }) + return child + } + + try { + await openWorkspaceTarget("terminal", root, "nested", undefined, { + openPath: async () => "", + revealPath: () => undefined, + spawnProcess, + }) + assert.equal(launches[0]?.cwd, nested) + assert.equal(launches[0]?.args.includes(nested), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/electron-app/electron/main/workspace-open.ts b/packages/electron-app/electron/main/workspace-open.ts new file mode 100644 index 000000000..4a4a11e19 --- /dev/null +++ b/packages/electron-app/electron/main/workspace-open.ts @@ -0,0 +1,238 @@ +import { spawn, type ChildProcess } from "node:child_process" +import { existsSync, realpathSync, statSync } from "node:fs" +import { extname, isAbsolute, join, relative, resolve } from "node:path" + +export type WorkspaceOpenTarget = "default" | "reveal" | "terminal" | "editor" +export type WorkspaceEditor = "vscode" | "cursor" | "zed" | "vscodium" + +interface LaunchCandidate { + command: string + args?: string[] + passPath?: boolean + waitForExit?: boolean + verifyStart?: boolean +} + +type SpawnProcess = (command: string, args: readonly string[], options: { + cwd: string + detached: boolean + stdio: "ignore" +}) => ChildProcess + +interface WorkspaceOpenDependencies { + openPath: (path: string) => Promise + revealPath: (path: string) => void + spawnProcess?: SpawnProcess +} + +const BLOCKED_DEFAULT_OPEN_EXTENSIONS = new Set([ + ".appimage", ".application", ".chm", ".com", ".cpl", ".desktop", ".exe", ".jar", ".lnk", ".msi", + ".msp", ".pif", ".scr", ".url", +]) + +const SAFE_WINDOWS_OPEN_EXTENSIONS = new Set([ + ".7z", ".avi", ".bmp", ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".csv", ".dart", + ".diff", ".docx", ".env", ".flac", ".fs", ".fsx", ".gif", ".go", ".gz", ".h", ".hpp", + ".htm", ".html", ".ini", ".java", ".jpeg", ".jpg", ".json", ".jsonc", ".jsx", ".kt", ".kts", + ".less", ".lock", ".log", ".lua", ".md", ".markdown", ".mkv", ".mov", ".mp3", ".mp4", ".ogg", + ".patch", ".pdf", ".png", ".pptx", ".r", ".rar", ".rmd", ".rs", ".scss", ".sql", ".svg", + ".svelte", ".swift", ".tar", ".toml", ".ts", ".tsx", ".txt", ".vue", ".wav", ".webm", ".webp", + ".xlsx", ".xml", ".yaml", ".yml", ".zip", +]) + +const WINDOWS_EDIT_EXTENSIONS = new Set([ + ".bat", ".cmd", ".js", ".jse", ".pl", ".ps1", ".psd1", ".psm1", ".py", ".pyw", ".rb", ".reg", + ".vbe", ".vbs", ".wsf", ".wsh", +]) + +export function defaultOpenMode(path: string, platform: NodeJS.Platform): "open" | "edit" | "choose" { + const stats = statSync(path) + const extension = extname(path).toLowerCase() + if (stats.isDirectory()) { + if (platform === "darwin" && extension === ".app") throw new Error("Application bundles cannot be opened externally") + return "open" + } + if (platform === "win32") { + if (SAFE_WINDOWS_OPEN_EXTENSIONS.has(extension)) return "open" + if (WINDOWS_EDIT_EXTENSIONS.has(extension)) return "edit" + return "choose" + } + if (BLOCKED_DEFAULT_OPEN_EXTENSIONS.has(extension)) { + throw new Error("Executable files cannot be opened externally") + } + if (stats.isFile() && (stats.mode & 0o111) !== 0) { + throw new Error("Executable files cannot be opened externally") + } + return "open" +} + +export function editorCandidates(editor: WorkspaceEditor, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): LaunchCandidate[] { + if (platform === "darwin") { + const names: Record = { + vscode: "Visual Studio Code", + cursor: "Cursor", + zed: "Zed", + vscodium: "VSCodium", + } + return [{ command: "/usr/bin/open", args: ["-a", names[editor]], waitForExit: true }] + } + + if (platform === "win32") { + const paths: Record> = { + vscode: [ + env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Microsoft VS Code", "Code.exe"), + env.ProgramFiles && join(env.ProgramFiles, "Microsoft VS Code", "Code.exe"), + ], + cursor: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "cursor", "Cursor.exe")], + zed: [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "Zed", "Zed.exe")], + vscodium: [ + env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "VSCodium", "VSCodium.exe"), + env.ProgramFiles && join(env.ProgramFiles, "VSCodium", "VSCodium.exe"), + ], + } + return paths[editor] + .filter((command): command is string => Boolean(command)) + .filter((command) => !isAbsolute(command) || existsSync(command)) + .map((command) => ({ command })) + } + + const commands: Record = { vscode: "code", cursor: "cursor", zed: "zed", vscodium: "codium" } + return [{ command: commands[editor], verifyStart: true }] +} + +export function terminalCandidates(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, folder = "."): LaunchCandidate[] { + if (platform === "darwin") return [{ command: "/usr/bin/open", args: ["-a", "Terminal"], waitForExit: true }] + if (platform === "win32") { + const systemRoot = env.SystemRoot?.trim() || "C:\\Windows" + const command = env.ComSpec?.trim() || join(systemRoot, "System32", "cmd.exe") + const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + const script = `Set-Location -LiteralPath '${folder.replace(/'/g, "''")}'` + const encoded = Buffer.from(script, "utf16le").toString("base64") + return [{ + command, + args: ["/D", "/C", "start", "", powershell, "-NoExit", "-EncodedCommand", encoded], + passPath: false, + waitForExit: true, + }] + } + + const configured = env.TERMINAL?.trim() + return [ + ...(configured ? [{ command: configured, passPath: false, verifyStart: true }] : []), + ...["xdg-terminal-exec", "x-terminal-emulator", "gnome-terminal", "konsole", "kitty", "alacritty", "wezterm"] + .map((command) => ({ command, passPath: false, verifyStart: true })), + ] +} + +function launch(candidate: LaunchCandidate, selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise { + return new Promise((resolve, reject) => { + let settled = false + let verificationTimer: ReturnType | undefined + const finish = (error?: Error) => { + if (settled) return + settled = true + if (verificationTimer) clearTimeout(verificationTimer) + if (error) reject(error) + else resolve() + } + const args = [...(candidate.args ?? []), ...(candidate.passPath === false ? [] : [selectedPath])] + const child = spawnProcess(candidate.command, args, { + cwd, + detached: true, + stdio: "ignore", + }) + child.once("spawn", () => { + if (!candidate.waitForExit) { + if (candidate.verifyStart) { + verificationTimer = setTimeout(() => { + child.unref() + finish() + }, 250) + return + } + child.unref() + finish() + } + }) + child.once("exit", (code) => { + if (!candidate.waitForExit && !candidate.verifyStart) return + if (code === 0) finish() + else finish(new Error(`${candidate.command} exited with code ${code ?? "unknown"}`)) + }) + child.once("error", (error) => finish(error)) + }) +} + +async function launchFirst(candidates: LaunchCandidate[], selectedPath: string, cwd: string, spawnProcess: SpawnProcess): Promise { + let lastError: unknown + for (const candidate of candidates) { + try { + await launch(candidate, selectedPath, cwd, spawnProcess) + return + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error("No supported application was found") +} + +export async function openWorkspaceTarget( + target: WorkspaceOpenTarget, + workspaceFolder: string, + path = ".", + editor?: WorkspaceEditor, + dependencies?: WorkspaceOpenDependencies, +): Promise { + if (!dependencies) throw new Error("Native workspace openers are unavailable") + const spawnProcess = dependencies.spawnProcess ?? spawn + const root = realpathSync(workspaceFolder) + if (!statSync(root).isDirectory()) throw new Error("Workspace folder does not exist") + const selectedPath = realpathSync(resolve(root, path)) + const relativePath = relative(root, selectedPath) + if (relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(relativePath)) { + throw new Error("Selected path is outside the workspace") + } + const cwd = statSync(selectedPath).isDirectory() ? selectedPath : resolve(selectedPath, "..") + + if (target === "default") { + const mode = defaultOpenMode(selectedPath, process.platform) + if (mode === "edit" || mode === "choose") { + const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows" + const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + const rundll32 = join(systemRoot, "System32", "rundll32.exe") + const script = `$ErrorActionPreference='Stop'; Start-Process -FilePath '${selectedPath.replace(/'/g, "''")}' -Verb Edit` + const encoded = Buffer.from(script, "utf16le").toString("base64") + const candidates: LaunchCandidate[] = [ + ...(mode === "edit" ? [{ + command: powershell, + args: ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], + passPath: false, + waitForExit: true, + }] : []), + { + command: rundll32, + args: ["shell32.dll,OpenAs_RunDLL"], + verifyStart: true, + }, + ] + await launchFirst(candidates, selectedPath, cwd, spawnProcess) + return + } + const error = await dependencies.openPath(selectedPath) + if (error) throw new Error(error) + return + } + + if (target === "reveal") { + dependencies.revealPath(selectedPath) + return + } + + if (!statSync(selectedPath).isDirectory() && target === "terminal") throw new Error("Terminal target is not a folder") + if (target === "editor" && !editor) throw new Error("Editor is required") + + const candidates = target === "terminal" + ? terminalCandidates(process.platform, process.env, selectedPath) + : editorCandidates(editor!, process.platform, process.env) + await launchFirst(candidates, selectedPath, cwd, spawnProcess) +} diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 0a35f6690..618b5858a 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -26,6 +26,13 @@ const localElectronAPI = { restartCli: () => ipcRenderer.invoke("cli:restart"), openDialog: (options) => ipcRenderer.invoke("dialog:open", options), getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), + openWorkspaceTarget: (payload) => ipcRenderer.invoke("workspace:openTarget", payload), + setWorkspaceMenuEnabled: (enabled) => ipcRenderer.invoke("workspace:setMenuEnabled", Boolean(enabled)), + onMenuAction: (callback) => { + const handler = (_event, action) => callback(action) + ipcRenderer.on("menu:action", handler) + return () => ipcRenderer.removeListener("menu:action", handler) + }, getPathForFile: (file) => { try { return webUtils.getPathForFile(file) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 339f56e89..f7b791470 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,7 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 4dc142612..4e9507896 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -10,6 +10,7 @@ mod linux_tls; mod managed_node; mod shutdown; mod windows_update; +mod workspace_open; use cli_manager::{CliProcessManager, CliStatus}; use desktop_event_transport::{ @@ -23,7 +24,9 @@ use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::menu::{AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, SubmenuBuilder}; +use tauri::menu::{ + AboutMetadata, MenuBuilder, MenuItem, PredefinedMenuItem, Submenu, SubmenuBuilder, +}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::webview::{PageLoadEvent, Webview}; use tauri::{ @@ -61,6 +64,70 @@ pub struct AppState { pub remote_skip_tls_verify: Mutex>, pub remote_tls_handlers: Mutex>, pub remote_titles: Mutex>, + pub workspace_menu_items: Mutex>, + pub workspace_menu_requested_enabled: Mutex, +} + +pub struct WorkspaceMenuItems { + folder: MenuItem, + terminal: MenuItem, + editor: Submenu, +} + +fn update_workspace_menu_state(app: &AppHandle) { + let state = app.state::(); + let requested = state + .workspace_menu_requested_enabled + .lock() + .map(|value| *value) + .unwrap_or(false); + let focused = app + .get_webview_window("main") + .and_then(|window| window.is_focused().ok()) + .unwrap_or(false); + if let Ok(items) = state.workspace_menu_items.lock() { + if let Some(items) = items.as_ref() { + let enabled = requested && focused; + let _ = items.folder.set_enabled(enabled); + let _ = items.terminal.set_enabled(enabled); + let _ = items.editor.set_enabled(enabled); + } + }; +} + +#[tauri::command] +fn set_workspace_menu_enabled( + window: tauri::WebviewWindow, + app: AppHandle, + state: tauri::State<'_, AppState>, + enabled: bool, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Workspace menu updates are limited to the local main window".into()); + } + if !enabled { + *state + .workspace_menu_requested_enabled + .lock() + .map_err(|error| error.to_string())? = false; + update_workspace_menu_state(&app); + return Ok(()); + } + let config = state + .manager + .desktop_event_stream_config() + .ok_or("Local CodeNomad server is unavailable")?; + let expected = Url::parse(&config.base_url).map_err(|error| error.to_string())?; + let current = window.url().map_err(|error| error.to_string())?; + if current.origin() != expected.origin() { + return Err("Workspace menu updates require the local CodeNomad origin".into()); + } + *state + .workspace_menu_requested_enabled + .lock() + .map_err(|error| error.to_string())? = enabled; + update_workspace_menu_state(&app); + Ok(()) } #[derive(Debug, Deserialize)] @@ -366,6 +433,9 @@ async fn open_remote_window_impl( let app_handle = app.clone(); let label_for_cleanup = label.clone(); window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(_)) { + update_workspace_menu_state(&app_handle); + } if let WindowEvent::Destroyed = event { if let Ok(mut origins) = app_handle.state::().remote_origins.lock() { origins.remove(&label_for_cleanup); @@ -605,8 +675,21 @@ fn main() { remote_skip_tls_verify: Mutex::new(HashMap::new()), remote_tls_handlers: Mutex::new(HashSet::new()), remote_titles: Mutex::new(HashMap::new()), + workspace_menu_items: Mutex::new(None), + workspace_menu_requested_enabled: Mutex::new(false), }) .on_page_load(|webview, payload| { + if webview.label() == "main" && payload.event() == PageLoadEvent::Started { + if let Ok(mut enabled) = webview + .app_handle() + .state::() + .workspace_menu_requested_enabled + .lock() + { + *enabled = false; + } + update_workspace_menu_state(&webview.app_handle()); + } if matches!( payload.event(), PageLoadEvent::Started | PageLoadEvent::Finished @@ -627,6 +710,12 @@ fn main() { shutdown::install_windows_session_end_handler(&window) .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT); + let app_handle = app.handle().clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(_)) { + update_workspace_menu_state(&app_handle); + } + }); } if let Some(shortcut) = fullscreen_shortcut() { let shortcut_manager = app.handle().global_shortcut(); @@ -673,14 +762,27 @@ fn main() { client_state::client_state_clear, client_state::client_state_renderer_flushed, client_state::client_state_navigation_flushed, - windows_update::install_stable_update + windows_update::install_stable_update, + workspace_open::open_workspace_target, + set_workspace_menu_enabled ]) .on_menu_event(|app_handle, event| { match event.id().0.as_str() { // File menu - "new_instance" => { + action @ ("new-instance" + | "open-workspace-folder" + | "open-workspace-terminal" + | "open-workspace-editor-vscode" + | "open-workspace-editor-cursor" + | "open-workspace-editor-zed" + | "open-workspace-editor-vscodium") => { if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.emit("menu:newInstance", ()); + if action.starts_with("open-workspace-") + && !window.is_focused().unwrap_or(false) + { + return; + } + let _ = window.emit("menu:action", action); } } "quit" => { @@ -883,22 +985,60 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> { // File menu - create New Instance with accelerator let new_instance_item = MenuItem::with_id( app, - "new_instance", + "new-instance", "New Instance", true, Some("CmdOrCtrl+N"), )?; + let open_folder_item = MenuItem::with_id( + app, + "open-workspace-folder", + "Open Project Folder", + true, + None::<&str>, + )?; + let open_terminal_item = MenuItem::with_id( + app, + "open-workspace-terminal", + "Open Terminal Here", + true, + None::<&str>, + )?; + let open_editor_menu = SubmenuBuilder::new(app, "Open Project In") + .text("open-workspace-editor-vscode", "VS Code") + .text("open-workspace-editor-cursor", "Cursor") + .text("open-workspace-editor-zed", "Zed") + .text("open-workspace-editor-vscodium", "VSCodium") + .build()?; + open_folder_item.set_enabled(false)?; + open_terminal_item.set_enabled(false)?; + open_editor_menu.set_enabled(false)?; + if let Ok(mut items) = app.state::().workspace_menu_items.lock() { + *items = Some(WorkspaceMenuItems { + folder: open_folder_item.clone(), + terminal: open_terminal_item.clone(), + editor: open_editor_menu.clone(), + }); + } let file_menu = if is_mac { SubmenuBuilder::new(app, "File") .item(&new_instance_item) .separator() + .item(&open_folder_item) + .item(&open_terminal_item) + .item(&open_editor_menu) + .separator() .close_window() .build()? } else { SubmenuBuilder::new(app, "File") .item(&new_instance_item) .separator() + .item(&open_folder_item) + .item(&open_terminal_item) + .item(&open_editor_menu) + .separator() .text("quit", "Quit") .build()? }; diff --git a/packages/tauri-app/src-tauri/src/workspace_open.rs b/packages/tauri-app/src-tauri/src/workspace_open.rs new file mode 100644 index 000000000..76cbded33 --- /dev/null +++ b/packages/tauri-app/src-tauri/src/workspace_open.rs @@ -0,0 +1,632 @@ +use crate::{cli_manager::CliProcessManager, AppState}; +#[cfg(windows)] +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use tauri::State; + +#[derive(Clone)] +struct LaunchCandidate { + command: PathBuf, + args: Vec, + pass_path: bool, + wait_for_exit: bool, + verify_start: bool, +} + +#[derive(Deserialize)] +struct WorkspaceDescriptor { + path: String, +} + +#[derive(Deserialize)] +struct WorktreeDescriptor { + slug: String, + directory: String, +} + +#[derive(Deserialize)] +struct WorktreeListResponse { + worktrees: Vec, +} + +fn get_local_json( + manager: &CliProcessManager, + path: &str, +) -> Result { + let config = manager + .desktop_event_stream_config() + .ok_or("Local CodeNomad server is unavailable")?; + let url = format!("{}{}", config.base_url.trim_end_matches('/'), path); + let client = reqwest::blocking::Client::new(); + let mut request = client.get(url); + if let Some(cookie) = config.session_cookie { + request = request.header( + reqwest::header::COOKIE, + format!("{}={cookie}", config.cookie_name), + ); + } + request + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .map_err(|error| error.to_string())? + .json::() + .map_err(|error| error.to_string()) +} + +fn resolve_active_workspace_folder( + manager: &CliProcessManager, + instance_id: &str, + worktree_slug: &str, +) -> Result { + if instance_id.is_empty() + || !instance_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err("Invalid workspace id".into()); + } + let workspace: WorkspaceDescriptor = + get_local_json(manager, &format!("/api/workspaces/{instance_id}"))?; + if worktree_slug == "root" { + return Ok(workspace.path); + } + let payload: WorktreeListResponse = + get_local_json(manager, &format!("/api/workspaces/{instance_id}/worktrees"))?; + payload + .worktrees + .into_iter() + .find(|worktree| worktree.slug == worktree_slug) + .map(|worktree| worktree.directory) + .ok_or_else(|| "Selected worktree is unavailable".into()) +} + +fn editor_candidates(editor: &str) -> Result, String> { + #[cfg(target_os = "macos")] + { + let name = match editor { + "vscode" => "Visual Studio Code", + "cursor" => "Cursor", + "zed" => "Zed", + "vscodium" => "VSCodium", + _ => return Err("Unsupported editor".into()), + }; + return Ok(vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: vec!["-a".into(), name.into()], + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]); + } + + #[cfg(windows)] + { + let paths: Vec = match editor { + "vscode" => vec![ + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/Microsoft VS Code/Code.exe")), + std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .map(|path| path.join("Microsoft VS Code/Code.exe")), + ], + "cursor" => vec![std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/cursor/Cursor.exe"))], + "zed" => vec![std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/Zed/Zed.exe"))], + "vscodium" => vec![ + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Programs/VSCodium/VSCodium.exe")), + std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .map(|path| path.join("VSCodium/VSCodium.exe")), + ], + _ => return Err("Unsupported editor".into()), + } + .into_iter() + .flatten() + .filter(|path| !path.is_absolute() || path.is_file()) + .collect(); + return Ok(paths + .into_iter() + .map(|command| LaunchCandidate { + command, + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: false, + }) + .collect()); + } + + #[cfg(all(not(windows), not(target_os = "macos")))] + { + let command = match editor { + "vscode" => "code", + "cursor" => "cursor", + "zed" => "zed", + "vscodium" => "codium", + _ => return Err("Unsupported editor".into()), + }; + Ok(vec![LaunchCandidate { + command: command.into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: true, + }]) + } +} + +fn terminal_candidates(folder: &Path) -> Vec { + #[cfg(target_os = "macos")] + return vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: vec!["-a".into(), "Terminal".into()], + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]; + + #[cfg(windows)] + { + let system_root = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| r"C:\Windows".into()); + let command = std::env::var_os("ComSpec") + .map(PathBuf::from) + .unwrap_or_else(|| system_root.join("System32/cmd.exe")); + let powershell = system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let escaped = folder.to_string_lossy().replace('\'', "''"); + let script = format!("Set-Location -LiteralPath '{escaped}'"); + return vec![LaunchCandidate { + command, + args: vec![ + "/D".into(), + "/C".into(), + "start".into(), + "".into(), + powershell.to_string_lossy().into_owned(), + "-NoExit".into(), + "-EncodedCommand".into(), + encode_powershell_script(&script), + ], + pass_path: false, + wait_for_exit: true, + verify_start: false, + }]; + } + + #[cfg(all(not(windows), not(target_os = "macos")))] + { + let configured = std::env::var("TERMINAL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .into_iter(); + configured + .chain( + [ + "xdg-terminal-exec", + "x-terminal-emulator", + "gnome-terminal", + "konsole", + "kitty", + "alacritty", + "wezterm", + ] + .map(str::to_string), + ) + .map(|command| LaunchCandidate { + command: command.into(), + args: Vec::new(), + pass_path: false, + wait_for_exit: false, + verify_start: true, + }) + .collect() + } +} + +#[cfg(windows)] +fn encode_powershell_script(script: &str) -> String { + let bytes: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + BASE64.encode(bytes) +} + +fn spawn_candidate( + candidate: &LaunchCandidate, + selected_path: &Path, + cwd: &Path, +) -> std::io::Result<()> { + let mut command = Command::new(&candidate.command); + command.args(&candidate.args); + if candidate.pass_path { + command.arg(selected_path); + } + let mut child = command + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + if candidate.wait_for_exit { + let status = child.wait()?; + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("{} exited with {status}", candidate.command.display()), + )); + } + } else if candidate.verify_start { + for _ in 0..10 { + if let Some(status) = child.try_wait()? { + if status.success() { + return Ok(()); + } + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("{} exited with {status}", candidate.command.display()), + )); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + std::thread::spawn(move || { + let _ = child.wait(); + }); + } + Ok(()) +} + +fn launch_first( + candidates: Vec, + selected_path: &Path, + cwd: &Path, +) -> Result<(), String> { + let mut last_error = None; + for candidate in candidates { + match spawn_candidate(&candidate, selected_path, cwd) { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + } + Err(last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "No supported application was found".into())) +} + +fn resolve_workspace_path( + workspace_folder: &str, + path: &str, +) -> Result<(PathBuf, PathBuf), String> { + let root = PathBuf::from(workspace_folder) + .canonicalize() + .map_err(|error| error.to_string())?; + if !root.is_dir() { + return Err("Workspace folder does not exist".into()); + } + let selected = root + .join(path) + .canonicalize() + .map_err(|error| error.to_string())?; + if !selected.starts_with(&root) { + return Err("Selected path is outside the workspace".into()); + } + Ok((root, selected)) +} + +#[cfg(windows)] +fn platform_launch_path(path: &Path) -> PathBuf { + let value = path.to_string_lossy(); + if let Some(rest) = value.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}").into(); + } + value.strip_prefix(r"\\?\").unwrap_or(&value).into() +} + +#[derive(Debug, PartialEq, Eq)] +enum DefaultOpenMode { + Open, + Edit, + Choose, +} + +fn default_open_mode(path: &Path) -> Result { + #[cfg(not(windows))] + const BLOCKED: &[&str] = &[ + "appimage", + "application", + "chm", + "com", + "cpl", + "desktop", + "exe", + "jar", + "lnk", + "msi", + "msp", + "pif", + "scr", + "url", + ]; + #[cfg(windows)] + const SAFE_WINDOWS_OPEN: &[&str] = &[ + "7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", + "docx", "env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", + "java", "jpeg", "jpg", "json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", + "md", "markdown", "mkv", "mov", "mp3", "mp4", "ogg", "patch", "pdf", "png", "pptx", "r", + "rar", "rmd", "rs", "scss", "sql", "svg", "svelte", "swift", "tar", "toml", "ts", "tsx", + "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip", + ]; + #[cfg(windows)] + const WINDOWS_EDIT: &[&str] = &[ + "bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", + "vbs", "wsf", "wsh", + ]; + let metadata = path.metadata().map_err(|error| error.to_string())?; + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if metadata.is_dir() { + if cfg!(target_os = "macos") && extension == "app" { + return Err("Application bundles cannot be opened externally".into()); + } + return Ok(DefaultOpenMode::Open); + } + #[cfg(windows)] + { + return Ok(if SAFE_WINDOWS_OPEN.contains(&extension.as_str()) { + DefaultOpenMode::Open + } else if WINDOWS_EDIT.contains(&extension.as_str()) { + DefaultOpenMode::Edit + } else { + DefaultOpenMode::Choose + }); + } + + #[cfg(unix)] + { + if BLOCKED.contains(&extension.as_str()) { + return Err("Executable files cannot be opened externally".into()); + } + if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 { + return Err("Executable files cannot be opened externally".into()); + } + return Ok(DefaultOpenMode::Open); + } + + #[cfg(not(any(windows, unix)))] + Ok(DefaultOpenMode::Open) +} + +#[cfg(not(windows))] +fn platform_launch_path(path: &Path) -> PathBuf { + path.to_path_buf() +} + +fn open_workspace_target_impl( + manager: &CliProcessManager, + target: String, + instance_id: String, + worktree_slug: String, + path: Option, + editor: Option, +) -> Result<(), String> { + let workspace_folder = resolve_active_workspace_folder(manager, &instance_id, &worktree_slug)?; + let (root, selected) = + resolve_workspace_path(&workspace_folder, path.as_deref().unwrap_or("."))?; + let cwd = if selected.is_dir() { + selected.clone() + } else { + selected.parent().unwrap_or(root.as_path()).to_path_buf() + }; + let launch_path = platform_launch_path(&selected); + let launch_cwd = platform_launch_path(&cwd); + + match target.as_str() { + "default" => { + let mode = default_open_mode(&selected)?; + #[cfg(windows)] + if mode != DefaultOpenMode::Open { + let system_root = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| r"C:\Windows".into()); + let powershell = system_root.join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let rundll32 = system_root.join("System32/rundll32.exe"); + let escaped = launch_path.to_string_lossy().replace('\'', "''"); + let script = format!( + "$ErrorActionPreference='Stop'; Start-Process -FilePath '{escaped}' -Verb Edit" + ); + let mut candidates = Vec::new(); + if mode == DefaultOpenMode::Edit { + candidates.push(LaunchCandidate { + command: powershell, + args: vec![ + "-NoProfile".into(), + "-NonInteractive".into(), + "-EncodedCommand".into(), + encode_powershell_script(&script), + ], + pass_path: false, + wait_for_exit: true, + verify_start: false, + }); + } + candidates.push(LaunchCandidate { + command: rundll32, + args: vec!["shell32.dll,OpenAs_RunDLL".into()], + pass_path: true, + wait_for_exit: false, + verify_start: true, + }); + return launch_first(candidates, &launch_path, &launch_cwd); + } + #[cfg(target_os = "macos")] + let candidates = vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]; + #[cfg(windows)] + let candidates = vec![LaunchCandidate { + command: "explorer.exe".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: false, + }]; + #[cfg(all(not(windows), not(target_os = "macos")))] + let candidates = vec![ + LaunchCandidate { + command: "xdg-open".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + LaunchCandidate { + command: "gio".into(), + args: vec!["open".into()], + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + ]; + launch_first(candidates, &launch_path, &launch_cwd) + } + "reveal" => { + #[cfg(target_os = "macos")] + let candidates = vec![LaunchCandidate { + command: "/usr/bin/open".into(), + args: vec!["-R".into()], + pass_path: true, + wait_for_exit: true, + verify_start: false, + }]; + #[cfg(windows)] + let candidates = vec![LaunchCandidate { + command: "explorer.exe".into(), + args: vec![format!("/select,{}", launch_path.display())], + pass_path: false, + wait_for_exit: false, + verify_start: false, + }]; + #[cfg(all(not(windows), not(target_os = "macos")))] + let candidates = vec![ + LaunchCandidate { + command: "xdg-open".into(), + args: Vec::new(), + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + LaunchCandidate { + command: "gio".into(), + args: vec!["open".into()], + pass_path: true, + wait_for_exit: false, + verify_start: true, + }, + ]; + let reveal_path = if cfg!(all(not(windows), not(target_os = "macos"))) { + launch_cwd.as_path() + } else { + launch_path.as_path() + }; + launch_first(candidates, reveal_path, &launch_cwd) + } + "terminal" => { + if !selected.is_dir() { + return Err("Terminal target is not a folder".into()); + } + launch_first(terminal_candidates(&launch_path), &launch_path, &launch_cwd) + } + "editor" => launch_first( + editor_candidates(editor.as_deref().ok_or("Editor is required")?)?, + &launch_path, + &launch_cwd, + ), + _ => Err("Unsupported workspace open target".into()), + } +} + +#[tauri::command] +pub async fn open_workspace_target( + window: tauri::WebviewWindow, + state: State<'_, AppState>, + target: String, + instance_id: String, + worktree_slug: String, + path: Option, + editor: Option, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Workspace open requests are limited to the local main window".into()); + } + let config = state + .manager + .desktop_event_stream_config() + .ok_or("Local CodeNomad server is unavailable")?; + let expected = url::Url::parse(&config.base_url).map_err(|error| error.to_string())?; + let current = window.url().map_err(|error| error.to_string())?; + if current.origin() != expected.origin() { + return Err("Workspace open requests require the local CodeNomad origin".into()); + } + + let manager = state.manager.clone(); + tauri::async_runtime::spawn_blocking(move || { + open_workspace_target_impl(&manager, target, instance_id, worktree_slug, path, editor) + }) + .await + .map_err(|error| error.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_paths_outside_the_workspace() { + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let result = resolve_workspace_path( + workspace.path().to_str().unwrap(), + outside.path().to_str().unwrap(), + ); + assert!(result.is_err()); + } + + #[test] + fn edits_windows_scripts_and_rejects_executable_default_open_targets() { + let workspace = tempfile::tempdir().unwrap(); + let script = workspace.path().join("run.cmd"); + std::fs::write(&script, "echo unsafe").unwrap(); + #[cfg(windows)] + assert_eq!(default_open_mode(&script).unwrap(), DefaultOpenMode::Edit); + let document = workspace.path().join("notes.txt"); + std::fs::write(&document, "safe").unwrap(); + #[cfg(windows)] + assert_eq!(default_open_mode(&document).unwrap(), DefaultOpenMode::Open); + let executable = workspace.path().join("run.exe"); + std::fs::write(&executable, "unsafe").unwrap(); + #[cfg(windows)] + assert_eq!( + default_open_mode(&executable).unwrap(), + DefaultOpenMode::Choose + ); + #[cfg(unix)] + assert!(default_open_mode(&executable).is_err()); + } +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 04f28790f..ac5d69f33 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -28,6 +28,8 @@ import { initReleaseNotifications } from "./stores/releases" import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env" import { useI18n } from "./lib/i18n" import { setWakeLockDesired } from "./lib/native/wake-lock" +import { resolveResolvable } from "./lib/commands" +import { setWorkspaceMenuEnabled } from "./lib/workspace-open" import { isSelectingFolder, setIsSelectingFolder, @@ -483,26 +485,42 @@ const App: Component = () => { getActiveSessionIdForInstance: activeSessionIdForInstance, }) - // Listen for Tauri menu events + // Native menus execute the same commands as the command palette. onMount(() => { + const executeMenuAction = (action: unknown) => { + if (typeof action !== "string") return + const command = paletteCommands().find((candidate) => candidate.id === action) + if (command && !(command.disabled && resolveResolvable(command.disabled))) executeCommand(command) + } + if (isTauriHost()) { const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__ if (tauriBridge?.event) { let unlistenMenu: (() => void) | null = null - tauriBridge.event.listen("menu:newInstance", () => { - handleNewInstanceRequest() + tauriBridge.event.listen("menu:action", (event) => { + executeMenuAction(event.payload) }).then((unlisten) => { unlistenMenu = unlisten }).catch((error) => { - log.error("Failed to listen for menu:newInstance event", error) + log.error("Failed to listen for native menu actions", error) }) onCleanup(() => { unlistenMenu?.() }) } + return } + + const unsubscribe = window.electronAPI?.onMenuAction?.(executeMenuAction) + onCleanup(() => unsubscribe?.()) + }) + + createEffect(() => { + void setWorkspaceMenuEnabled(Boolean(activeInstance())).catch((error) => { + log.warn("Failed to update native workspace menu state", error) + }) }) return ( diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index b9f6ceec2..65f07f58e 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -1,18 +1,42 @@ import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js" import type { FileNode } from "@opencode-ai/sdk/v2/client" -import { Copy, RefreshCw, Save, Search, WrapText } from "lucide-solid" +import { Code2, Copy, ExternalLink, FolderOpen, RefreshCw, Save, Search, TerminalSquare, WrapText } from "lucide-solid" import SplitFilePanel from "../components/SplitFilePanel" import { Markdown } from "../../../../markdown" import { copyToClipboard } from "../../../../../lib/clipboard" import { showToastNotification } from "../../../../../lib/notifications" import { useTheme } from "../../../../../lib/theme" +import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../../action-overflow-menu" +import { canOpenWorkspacePaths, openWorkspacePath, type WorkspaceEditor } from "../../../../../lib/workspace-open" const LazyMonacoFileViewer = lazy(() => import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })), ) +const isWindowsDesktop = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent) +const isMacDesktop = typeof navigator !== "undefined" && /macintosh|mac os x/i.test(navigator.userAgent) +const safeWindowsOpenExtensions = new Set([ + "7z", "avi", "bmp", "c", "cc", "cfg", "conf", "cpp", "cs", "css", "csv", "dart", "diff", "docx", + "env", "flac", "fs", "fsx", "gif", "go", "gz", "h", "hpp", "htm", "html", "ini", "java", "jpeg", "jpg", + "json", "jsonc", "jsx", "kt", "kts", "less", "lock", "log", "lua", "md", "markdown", "mkv", "mov", "mp3", + "mp4", "ogg", "patch", "pdf", "png", "pptx", "r", "rar", "rmd", "rs", "scss", "sql", "svg", "svelte", + "swift", "tar", "toml", "ts", "tsx", "txt", "vue", "wav", "webm", "webp", "xlsx", "xml", "yaml", "yml", "zip", +]) +const windowsEditExtensions = new Set([ + "bat", "cmd", "js", "jse", "pl", "ps1", "psd1", "psm1", "py", "pyw", "rb", "reg", "vbe", "vbs", "wsf", "wsh", +]) + +function windowsDefaultAction(path: string): "open" | "edit" | "choose" { + const name = path.split(/[\\/]/).pop() ?? "" + const dot = name.lastIndexOf(".") + const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : "" + if (!isWindowsDesktop || (extension && safeWindowsOpenExtensions.has(extension))) return "open" + if (extension && windowsEditExtensions.has(extension)) return "edit" + return "choose" +} + function isMarkdownPath(path: string | null | undefined): boolean { if (!path) return false return /\.(md|markdown|mdown|mkdn)$/i.test(path) @@ -36,6 +60,8 @@ interface FilesTabProps { parentPath: Accessor scopeKey: Accessor + instanceId: string + worktreeSlug: Accessor onLoadEntries: (path: string) => void onRequestOpenFile: (path: string) => void @@ -114,6 +140,88 @@ const FilesTab: Component = (props) => { }) } + const handleNativeOpen = async ( + target: "default" | "reveal" | "terminal" | "editor", + path: string, + editor?: WorkspaceEditor, + ) => { + try { + await openWorkspacePath({ + target, + instanceId: props.instanceId, + worktreeSlug: props.worktreeSlug(), + path, + editor, + }) + } catch (error) { + showToastNotification({ + message: props.t("instanceShell.filesShell.toast.openError", { + message: error instanceof Error ? error.message : String(error), + }), + variant: "error", + }) + } + } + + const rowActions = (item: FileNode): ActionOverflowMenuItem[] => { + const items: ActionOverflowMenuItem[] = [] + if (canOpenWorkspacePaths()) { + if (item.type === "directory") { + const isMacApp = isMacDesktop && item.path.toLowerCase().endsWith(".app") + items.push( + { + key: "open-folder", + label: props.t(isMacApp + ? "instanceShell.filesShell.actions.showInFolder" + : "instanceShell.filesShell.actions.openFolder"), + icon: , + onSelect: () => handleNativeOpen(isMacApp ? "reveal" : "default", item.path), + }, + { + key: "open-terminal", + label: props.t("instanceShell.filesShell.actions.openTerminal"), + icon: , + onSelect: () => handleNativeOpen("terminal", item.path), + }, + ) + for (const [editor, name] of [["vscode", "VS Code"], ["cursor", "Cursor"], ["zed", "Zed"], ["vscodium", "VSCodium"]] as const) { + items.push({ + key: `open-editor-${editor}`, + label: props.t("instanceShell.filesShell.actions.openEditor", { editor: name }), + icon: , + onSelect: () => handleNativeOpen("editor", item.path, editor), + }) + } + } else { + if (isWindowsDesktop) { + items.push({ + key: "open-default", + label: props.t({ + open: "instanceShell.filesShell.actions.openDefault", + edit: "instanceShell.filesShell.actions.editDefault", + choose: "instanceShell.filesShell.actions.chooseApplication", + }[windowsDefaultAction(item.path)]), + icon: , + onSelect: () => handleNativeOpen("default", item.path), + }) + } + items.push({ + key: "show-in-folder", + label: props.t("instanceShell.filesShell.actions.showInFolder"), + icon: , + onSelect: () => handleNativeOpen("reveal", item.path), + }) + } + } + items.push({ + key: "copy-path", + label: props.t("instanceShell.filesShell.actions.copyPath"), + icon: , + onSelect: () => handleCopyPath(item.path), + }) + return items + } + createEffect(() => { if (!showingMarkdownPreview()) return requestAnimationFrame(() => markdownPreviewRef?.focus()) @@ -189,15 +297,28 @@ const FilesTab: Component = (props) => {
{item.type}
- + } > - - +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + +
+ diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx index 462b20a1e..105849987 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -296,6 +296,8 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS wordWrapMode={filesWordWrapMode} parentPath={browserParentPath} scopeKey={browserScopeKey} + instanceId={options.instanceId} + worktreeSlug={options.worktreeSlug} onLoadEntries={(path: string) => void loadBrowserEntries(path)} onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} onRefresh={() => void refreshFilesTab()} diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index e11c97650..9460bf6b5 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -21,6 +21,8 @@ import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" import { registerBehaviorCommands } from "../settings/behavior-registry" +import { canOpenWorkspacePaths, openWorkspacePath, type WorkspaceEditor, type WorkspaceOpenTarget } from "../workspace-open" +import { getDefaultWorktreeSlug, getWorktreeSlugForSession } from "../../stores/worktrees" const log = getLogger("actions") @@ -82,6 +84,36 @@ export function useCommands(options: UseCommandsOptions) { const activeInstance = options.getActiveInstance const activeSessionIdForInstance = options.getActiveSessionIdForInstance + const activeWorkspace = () => { + const instance = activeInstance() + if (!instance) return null + const sessionId = activeSessionIdForInstance() + const worktreeSlug = !sessionId || sessionId === "info" + ? getDefaultWorktreeSlug(instance.id) + : getWorktreeSlugForSession(instance.id, sessionId) + return { instanceId: instance.id, worktreeSlug } + } + + const openActiveWorkspace = async (target: WorkspaceOpenTarget, editor?: WorkspaceEditor) => { + const workspace = activeWorkspace() + if (!workspace) { + showAlertDialog(tGlobal("commands.openWorkspace.projectRequired.message"), { + title: tGlobal("commands.openWorkspace.projectRequired.title"), + }) + return + } + try { + await openWorkspacePath({ target, ...workspace, editor }) + } catch (error) { + showAlertDialog(tGlobal("commands.openWorkspace.failed.message", { + message: error instanceof Error ? error.message : String(error), + }), { + title: tGlobal("commands.openWorkspace.failed.title"), + variant: "error", + }) + } + } + commandRegistry.register({ id: "new-instance", label: () => tGlobal("commands.newInstance.label"), @@ -104,6 +136,44 @@ export function useCommands(options: UseCommandsOptions) { }, }) + if (canOpenWorkspacePaths()) { + commandRegistry.register({ + id: "open-workspace-folder", + label: () => tGlobal("commands.openWorkspace.folder.label"), + description: () => tGlobal("commands.openWorkspace.folder.description"), + category: "Instance", + keywords: () => splitKeywords("commands.openWorkspace.folder.keywords"), + disabled: () => !activeInstance(), + action: () => openActiveWorkspace("default"), + }) + commandRegistry.register({ + id: "open-workspace-terminal", + label: () => tGlobal("commands.openWorkspace.terminal.label"), + description: () => tGlobal("commands.openWorkspace.terminal.description"), + category: "Instance", + keywords: () => splitKeywords("commands.openWorkspace.terminal.keywords"), + disabled: () => !activeInstance(), + action: () => openActiveWorkspace("terminal"), + }) + const editors: Array<[WorkspaceEditor, string]> = [ + ["vscode", "VS Code"], + ["cursor", "Cursor"], + ["zed", "Zed"], + ["vscodium", "VSCodium"], + ] + for (const [editor, name] of editors) { + commandRegistry.register({ + id: `open-workspace-editor-${editor}`, + label: () => tGlobal("commands.openWorkspace.editor.label", { editor: name }), + description: () => tGlobal("commands.openWorkspace.editor.description", { editor: name }), + category: "Instance", + keywords: () => [name, ...splitKeywords("commands.openWorkspace.editor.keywords")], + disabled: () => !activeInstance(), + action: () => openActiveWorkspace("editor", editor), + }) + } + } + commandRegistry.register({ id: "instance-next", label: () => tGlobal("commands.nextInstance.label"), diff --git a/packages/ui/src/lib/i18n/messages/de/commands.ts b/packages/ui/src/lib/i18n/messages/de/commands.ts index 5962a1aa5..82e965145 100644 --- a/packages/ui/src/lib/i18n/messages/de/commands.ts +++ b/packages/ui/src/lib/i18n/messages/de/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "Neue Instanz", "commands.newInstance.description": "Ordnerauswahl öffnen, um eine neue Instanz zu erstellen", "commands.newInstance.keywords": "ordner, projekt, arbeitsbereich", + "commands.openWorkspace.folder.label": "Projektordner öffnen", + "commands.openWorkspace.folder.description": "Aktuellen Projektordner im Dateimanager öffnen", + "commands.openWorkspace.folder.keywords": "Ordner, Explorer, Finder, Dateien", + "commands.openWorkspace.terminal.label": "Terminal hier öffnen", + "commands.openWorkspace.terminal.description": "Externes Terminal im aktuellen Projektordner öffnen", + "commands.openWorkspace.terminal.keywords": "Terminal, Shell, Konsole", + "commands.openWorkspace.editor.label": "Projekt in {editor} öffnen", + "commands.openWorkspace.editor.description": "Aktuellen Projektordner in {editor} öffnen", + "commands.openWorkspace.editor.keywords": "Editor, IDE, Code", + "commands.openWorkspace.failed.title": "Projekt konnte nicht geöffnet werden", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "Kein Projekt geöffnet", + "commands.openWorkspace.projectRequired.message": "Öffnen Sie zuerst ein Projekt.", "commands.closeInstance.label": "Tab schließen", "commands.closeInstance.description": "Den aktuellen obersten Tab schließen", diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index d3eaa2635..c622b2f05 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "Dateien in diesem Ordner filtern", "instanceShell.filesShell.search.empty": "Keine passenden Dateien.", "instanceShell.filesShell.actions.copyPath": "Pfad kopieren", + "instanceShell.filesShell.actions.more": "Weitere Aktionen für {name}", + "instanceShell.filesShell.actions.openDefault": "Mit Standardanwendung öffnen", + "instanceShell.filesShell.actions.editDefault": "Mit Standardanwendung bearbeiten", + "instanceShell.filesShell.actions.chooseApplication": "Öffnen mit...", + "instanceShell.filesShell.actions.showInFolder": "Im Ordner anzeigen", + "instanceShell.filesShell.actions.openFolder": "Im Dateimanager öffnen", + "instanceShell.filesShell.actions.openTerminal": "Terminal hier öffnen", + "instanceShell.filesShell.actions.openEditor": "In {editor} öffnen", + "instanceShell.filesShell.toast.openError": "Öffnen fehlgeschlagen: {message}", "instanceShell.filesShell.toast.copyPathSuccess": "Pfad kopiert", "instanceShell.filesShell.toast.copyPathError": "Pfad konnte nicht kopiert werden", "instanceShell.filesShell.previewMarkdown": "Markdown-Vorschau", diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index 3dabac04e..cfabd78a6 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "New Instance", "commands.newInstance.description": "Open folder picker to create new instance", "commands.newInstance.keywords": "folder, project, workspace", + "commands.openWorkspace.folder.label": "Open Project Folder", + "commands.openWorkspace.folder.description": "Open the current project folder in the file manager", + "commands.openWorkspace.folder.keywords": "folder, explorer, finder, files", + "commands.openWorkspace.terminal.label": "Open Terminal Here", + "commands.openWorkspace.terminal.description": "Open an external terminal in the current project folder", + "commands.openWorkspace.terminal.keywords": "terminal, shell, console", + "commands.openWorkspace.editor.label": "Open Project in {editor}", + "commands.openWorkspace.editor.description": "Open the current project folder in {editor}", + "commands.openWorkspace.editor.keywords": "editor, ide, code", + "commands.openWorkspace.failed.title": "Could not open project", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "No project open", + "commands.openWorkspace.projectRequired.message": "Open a project first.", "commands.closeInstance.label": "Close Tab", "commands.closeInstance.description": "Close the current top-level tab", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index bd4fb6990..dc3d40fb1 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "Filter files in this folder", "instanceShell.filesShell.search.empty": "No matching files.", "instanceShell.filesShell.actions.copyPath": "Copy path", + "instanceShell.filesShell.actions.more": "More actions for {name}", + "instanceShell.filesShell.actions.openDefault": "Open with default application", + "instanceShell.filesShell.actions.editDefault": "Edit with default application", + "instanceShell.filesShell.actions.chooseApplication": "Open with...", + "instanceShell.filesShell.actions.showInFolder": "Show in folder", + "instanceShell.filesShell.actions.openFolder": "Open in file manager", + "instanceShell.filesShell.actions.openTerminal": "Open terminal here", + "instanceShell.filesShell.actions.openEditor": "Open in {editor}", + "instanceShell.filesShell.toast.openError": "Could not open: {message}", "instanceShell.filesShell.toast.copyPathSuccess": "Copied path", "instanceShell.filesShell.toast.copyPathError": "Failed to copy path", "instanceShell.filesShell.previewMarkdown": "Preview Markdown", diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index 68e9c3029..6bd4dd7c8 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "Nueva instancia", "commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia", "commands.newInstance.keywords": "carpeta, proyecto, workspace", + "commands.openWorkspace.folder.label": "Abrir carpeta del proyecto", + "commands.openWorkspace.folder.description": "Abrir la carpeta del proyecto actual en el explorador de archivos", + "commands.openWorkspace.folder.keywords": "carpeta, explorador, archivos", + "commands.openWorkspace.terminal.label": "Abrir terminal aquí", + "commands.openWorkspace.terminal.description": "Abrir un terminal externo en la carpeta del proyecto actual", + "commands.openWorkspace.terminal.keywords": "terminal, shell, consola", + "commands.openWorkspace.editor.label": "Abrir proyecto en {editor}", + "commands.openWorkspace.editor.description": "Abrir la carpeta del proyecto actual en {editor}", + "commands.openWorkspace.editor.keywords": "editor, ide, código", + "commands.openWorkspace.failed.title": "No se pudo abrir el proyecto", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "No hay ningún proyecto abierto", + "commands.openWorkspace.projectRequired.message": "Abre primero un proyecto.", "commands.closeInstance.label": "Cerrar pestaña", "commands.closeInstance.description": "Cerrar la pestaña superior actual", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index e24672a82..dbd096b34 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "Filtrar archivos de esta carpeta", "instanceShell.filesShell.search.empty": "No hay archivos coincidentes.", "instanceShell.filesShell.actions.copyPath": "Copiar ruta", + "instanceShell.filesShell.actions.more": "Más acciones para {name}", + "instanceShell.filesShell.actions.openDefault": "Abrir con la aplicación predeterminada", + "instanceShell.filesShell.actions.editDefault": "Editar con la aplicación predeterminada", + "instanceShell.filesShell.actions.chooseApplication": "Abrir con...", + "instanceShell.filesShell.actions.showInFolder": "Mostrar en la carpeta", + "instanceShell.filesShell.actions.openFolder": "Abrir en el explorador de archivos", + "instanceShell.filesShell.actions.openTerminal": "Abrir terminal aquí", + "instanceShell.filesShell.actions.openEditor": "Abrir en {editor}", + "instanceShell.filesShell.toast.openError": "No se pudo abrir: {message}", "instanceShell.filesShell.toast.copyPathSuccess": "Ruta copiada", "instanceShell.filesShell.toast.copyPathError": "No se pudo copiar la ruta", "instanceShell.filesShell.previewMarkdown": "Vista previa Markdown", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index f846bc883..683b42217 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "Nouvelle instance", "commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance", "commands.newInstance.keywords": "dossier, projet, espace de travail", + "commands.openWorkspace.folder.label": "Ouvrir le dossier du projet", + "commands.openWorkspace.folder.description": "Ouvrir le dossier du projet actuel dans le gestionnaire de fichiers", + "commands.openWorkspace.folder.keywords": "dossier, explorateur, fichiers", + "commands.openWorkspace.terminal.label": "Ouvrir un terminal ici", + "commands.openWorkspace.terminal.description": "Ouvrir un terminal externe dans le dossier du projet actuel", + "commands.openWorkspace.terminal.keywords": "terminal, shell, console", + "commands.openWorkspace.editor.label": "Ouvrir le projet dans {editor}", + "commands.openWorkspace.editor.description": "Ouvrir le dossier du projet actuel dans {editor}", + "commands.openWorkspace.editor.keywords": "éditeur, ide, code", + "commands.openWorkspace.failed.title": "Impossible d'ouvrir le projet", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "Aucun projet ouvert", + "commands.openWorkspace.projectRequired.message": "Ouvrez d'abord un projet.", "commands.closeInstance.label": "Fermer l'onglet", "commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 51b865dc0..f14360a34 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -165,6 +165,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "Filtrer les fichiers de ce dossier", "instanceShell.filesShell.search.empty": "Aucun fichier correspondant.", "instanceShell.filesShell.actions.copyPath": "Copier le chemin", + "instanceShell.filesShell.actions.more": "Plus d'actions pour {name}", + "instanceShell.filesShell.actions.openDefault": "Ouvrir avec l'application par défaut", + "instanceShell.filesShell.actions.editDefault": "Modifier avec l'application par défaut", + "instanceShell.filesShell.actions.chooseApplication": "Ouvrir avec...", + "instanceShell.filesShell.actions.showInFolder": "Afficher dans le dossier", + "instanceShell.filesShell.actions.openFolder": "Ouvrir dans le gestionnaire de fichiers", + "instanceShell.filesShell.actions.openTerminal": "Ouvrir un terminal ici", + "instanceShell.filesShell.actions.openEditor": "Ouvrir dans {editor}", + "instanceShell.filesShell.toast.openError": "Impossible d'ouvrir : {message}", "instanceShell.filesShell.toast.copyPathSuccess": "Chemin copié", "instanceShell.filesShell.toast.copyPathError": "Impossible de copier le chemin", "instanceShell.filesShell.previewMarkdown": "Aperçu Markdown", diff --git a/packages/ui/src/lib/i18n/messages/he/commands.ts b/packages/ui/src/lib/i18n/messages/he/commands.ts index a432e042e..7cb775833 100644 --- a/packages/ui/src/lib/i18n/messages/he/commands.ts +++ b/packages/ui/src/lib/i18n/messages/he/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "מופע חדש", "commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש", "commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה", + "commands.openWorkspace.folder.label": "פתיחת תיקיית הפרויקט", + "commands.openWorkspace.folder.description": "פתיחת תיקיית הפרויקט הנוכחי במנהל הקבצים", + "commands.openWorkspace.folder.keywords": "תיקייה, קבצים, סייר", + "commands.openWorkspace.terminal.label": "פתיחת מסוף כאן", + "commands.openWorkspace.terminal.description": "פתיחת מסוף חיצוני בתיקיית הפרויקט הנוכחי", + "commands.openWorkspace.terminal.keywords": "מסוף, shell, קונסולה", + "commands.openWorkspace.editor.label": "פתיחת הפרויקט ב-{editor}", + "commands.openWorkspace.editor.description": "פתיחת תיקיית הפרויקט הנוכחי ב-{editor}", + "commands.openWorkspace.editor.keywords": "עורך, ide, קוד", + "commands.openWorkspace.failed.title": "לא ניתן לפתוח את הפרויקט", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "אין פרויקט פתוח", + "commands.openWorkspace.projectRequired.message": "יש לפתוח פרויקט תחילה.", "commands.closeInstance.label": "סגור לשונית", "commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index 3e42ff082..f5974b8b4 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "סנן קבצים בתיקייה הזו", "instanceShell.filesShell.search.empty": "לא נמצאו קבצים תואמים.", "instanceShell.filesShell.actions.copyPath": "העתק נתיב", + "instanceShell.filesShell.actions.more": "פעולות נוספות עבור {name}", + "instanceShell.filesShell.actions.openDefault": "פתיחה באמצעות יישום ברירת המחדל", + "instanceShell.filesShell.actions.editDefault": "עריכה באמצעות יישום ברירת המחדל", + "instanceShell.filesShell.actions.chooseApplication": "פתיחה באמצעות...", + "instanceShell.filesShell.actions.showInFolder": "הצגה בתיקייה", + "instanceShell.filesShell.actions.openFolder": "פתיחה במנהל הקבצים", + "instanceShell.filesShell.actions.openTerminal": "פתיחת מסוף כאן", + "instanceShell.filesShell.actions.openEditor": "פתיחה ב-{editor}", + "instanceShell.filesShell.toast.openError": "לא ניתן לפתוח: {message}", "instanceShell.filesShell.toast.copyPathSuccess": "הנתיב הועתק", "instanceShell.filesShell.toast.copyPathError": "העתקת הנתיב נכשלה", "instanceShell.filesShell.previewMarkdown": "תצוגת Markdown", diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index c00b13d44..5e2a5bce8 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "新しいインスタンス", "commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成", "commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace", + "commands.openWorkspace.folder.label": "プロジェクトフォルダーを開く", + "commands.openWorkspace.folder.description": "現在のプロジェクトフォルダーをファイルマネージャーで開く", + "commands.openWorkspace.folder.keywords": "フォルダー, エクスプローラー, ファイル", + "commands.openWorkspace.terminal.label": "ここでターミナルを開く", + "commands.openWorkspace.terminal.description": "現在のプロジェクトフォルダーで外部ターミナルを開く", + "commands.openWorkspace.terminal.keywords": "ターミナル, シェル, コンソール", + "commands.openWorkspace.editor.label": "プロジェクトを{editor}で開く", + "commands.openWorkspace.editor.description": "現在のプロジェクトフォルダーを{editor}で開く", + "commands.openWorkspace.editor.keywords": "エディター, ide, コード", + "commands.openWorkspace.failed.title": "プロジェクトを開けませんでした", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "プロジェクトが開かれていません", + "commands.openWorkspace.projectRequired.message": "先にプロジェクトを開いてください。", "commands.closeInstance.label": "タブを閉じる", "commands.closeInstance.description": "現在のトップレベルタブを閉じる", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index 51952de54..b061b71f7 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "このフォルダーのファイルを絞り込む", "instanceShell.filesShell.search.empty": "一致するファイルがありません。", "instanceShell.filesShell.actions.copyPath": "パスをコピー", + "instanceShell.filesShell.actions.more": "{name}のその他の操作", + "instanceShell.filesShell.actions.openDefault": "既定のアプリケーションで開く", + "instanceShell.filesShell.actions.editDefault": "既定のアプリケーションで編集", + "instanceShell.filesShell.actions.chooseApplication": "アプリケーションを選択して開く...", + "instanceShell.filesShell.actions.showInFolder": "フォルダーに表示", + "instanceShell.filesShell.actions.openFolder": "ファイルマネージャーで開く", + "instanceShell.filesShell.actions.openTerminal": "ここでターミナルを開く", + "instanceShell.filesShell.actions.openEditor": "{editor}で開く", + "instanceShell.filesShell.toast.openError": "開けませんでした: {message}", "instanceShell.filesShell.toast.copyPathSuccess": "パスをコピーしました", "instanceShell.filesShell.toast.copyPathError": "パスをコピーできませんでした", "instanceShell.filesShell.previewMarkdown": "Markdown プレビュー", diff --git a/packages/ui/src/lib/i18n/messages/ne/commands.ts b/packages/ui/src/lib/i18n/messages/ne/commands.ts index f7c9eab64..35bc74aaf 100644 --- a/packages/ui/src/lib/i18n/messages/ne/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ne/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "नयाँ उदाहरण (New Instance)", "commands.newInstance.description": "नयाँ उदाहरण सिर्जना गर्न फोल्डर चयनकर्ता खोल्नुहोस्", "commands.newInstance.keywords": "फोल्डर, परियोजना, कार्यस्थान", + "commands.openWorkspace.folder.label": "परियोजना फोल्डर खोल्नुहोस्", + "commands.openWorkspace.folder.description": "हालको परियोजना फोल्डर फाइल व्यवस्थापकमा खोल्नुहोस्", + "commands.openWorkspace.folder.keywords": "फोल्डर, फाइल, एक्सप्लोरर", + "commands.openWorkspace.terminal.label": "यहाँ टर्मिनल खोल्नुहोस्", + "commands.openWorkspace.terminal.description": "हालको परियोजना फोल्डरमा बाह्य टर्मिनल खोल्नुहोस्", + "commands.openWorkspace.terminal.keywords": "टर्मिनल, shell, कन्सोल", + "commands.openWorkspace.editor.label": "परियोजना {editor} मा खोल्नुहोस्", + "commands.openWorkspace.editor.description": "हालको परियोजना फोल्डर {editor} मा खोल्नुहोस्", + "commands.openWorkspace.editor.keywords": "सम्पादक, ide, कोड", + "commands.openWorkspace.failed.title": "परियोजना खोल्न सकिएन", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "कुनै परियोजना खुलेको छैन", + "commands.openWorkspace.projectRequired.message": "पहिले परियोजना खोल्नुहोस्।", "commands.closeInstance.label": "ट्याब बन्द गर्नुहोस्", "commands.closeInstance.description": "हालको शीर्ष-स्तरको ट्याब बन्द गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index 5bb3f3e0a..760065115 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "यस फोल्डरमा फाइलहरू फिल्टर गर्नुहोस्", "instanceShell.filesShell.search.empty": "कुनै मिल्दो फाइलहरू छैनन्।", "instanceShell.filesShell.actions.copyPath": "मार्ग (Path) प्रतिलिपि गर्नुहोस्", + "instanceShell.filesShell.actions.more": "{name} का लागि थप कार्यहरू", + "instanceShell.filesShell.actions.openDefault": "पूर्वनिर्धारित अनुप्रयोगमा खोल्नुहोस्", + "instanceShell.filesShell.actions.editDefault": "पूर्वनिर्धारित अनुप्रयोगमा सम्पादन गर्नुहोस्", + "instanceShell.filesShell.actions.chooseApplication": "यससँग खोल्नुहोस्...", + "instanceShell.filesShell.actions.showInFolder": "फोल्डरमा देखाउनुहोस्", + "instanceShell.filesShell.actions.openFolder": "फाइल व्यवस्थापकमा खोल्नुहोस्", + "instanceShell.filesShell.actions.openTerminal": "यहाँ टर्मिनल खोल्नुहोस्", + "instanceShell.filesShell.actions.openEditor": "{editor} मा खोल्नुहोस्", + "instanceShell.filesShell.toast.openError": "खोल्न सकिएन: {message}", "instanceShell.filesShell.toast.copyPathSuccess": "मार्ग प्रतिलिपि गरियो", "instanceShell.filesShell.toast.copyPathError": "मार्ग प्रतिलिपि गर्न असफल भयो", "instanceShell.filesShell.previewMarkdown": "Markdown पूर्वावलोकन", diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 8d674f48a..782b5a621 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "Новый экземпляр", "commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра", "commands.newInstance.keywords": "папка, проект, рабочее пространство", + "commands.openWorkspace.folder.label": "Открыть папку проекта", + "commands.openWorkspace.folder.description": "Открыть папку текущего проекта в файловом менеджере", + "commands.openWorkspace.folder.keywords": "папка, проводник, файлы", + "commands.openWorkspace.terminal.label": "Открыть терминал здесь", + "commands.openWorkspace.terminal.description": "Открыть внешний терминал в папке текущего проекта", + "commands.openWorkspace.terminal.keywords": "терминал, оболочка, консоль", + "commands.openWorkspace.editor.label": "Открыть проект в {editor}", + "commands.openWorkspace.editor.description": "Открыть папку текущего проекта в {editor}", + "commands.openWorkspace.editor.keywords": "редактор, ide, код", + "commands.openWorkspace.failed.title": "Не удалось открыть проект", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "Проект не открыт", + "commands.openWorkspace.projectRequired.message": "Сначала откройте проект.", "commands.closeInstance.label": "Закрыть вкладку", "commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index 80437e052..53b6e0bf2 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "Фильтровать файлы в этой папке", "instanceShell.filesShell.search.empty": "Совпадающих файлов нет.", "instanceShell.filesShell.actions.copyPath": "Скопировать путь", + "instanceShell.filesShell.actions.more": "Дополнительные действия для {name}", + "instanceShell.filesShell.actions.openDefault": "Открыть в приложении по умолчанию", + "instanceShell.filesShell.actions.editDefault": "Изменить в приложении по умолчанию", + "instanceShell.filesShell.actions.chooseApplication": "Открыть с помощью...", + "instanceShell.filesShell.actions.showInFolder": "Показать в папке", + "instanceShell.filesShell.actions.openFolder": "Открыть в файловом менеджере", + "instanceShell.filesShell.actions.openTerminal": "Открыть терминал здесь", + "instanceShell.filesShell.actions.openEditor": "Открыть в {editor}", + "instanceShell.filesShell.toast.openError": "Не удалось открыть: {message}", "instanceShell.filesShell.toast.copyPathSuccess": "Путь скопирован", "instanceShell.filesShell.toast.copyPathError": "Не удалось скопировать путь", "instanceShell.filesShell.previewMarkdown": "Предпросмотр Markdown", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index a8078c143..eabf60362 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -14,6 +14,19 @@ export const commandMessages = { "commands.newInstance.label": "新建实例", "commands.newInstance.description": "打开文件夹选择器以创建新实例", "commands.newInstance.keywords": "folder, project, workspace, 文件夹, 项目, 工作区", + "commands.openWorkspace.folder.label": "打开项目文件夹", + "commands.openWorkspace.folder.description": "在文件管理器中打开当前项目文件夹", + "commands.openWorkspace.folder.keywords": "文件夹, 资源管理器, 文件", + "commands.openWorkspace.terminal.label": "在此处打开终端", + "commands.openWorkspace.terminal.description": "在当前项目文件夹中打开外部终端", + "commands.openWorkspace.terminal.keywords": "终端, shell, 控制台", + "commands.openWorkspace.editor.label": "在{editor}中打开项目", + "commands.openWorkspace.editor.description": "在{editor}中打开当前项目文件夹", + "commands.openWorkspace.editor.keywords": "编辑器, ide, 代码", + "commands.openWorkspace.failed.title": "无法打开项目", + "commands.openWorkspace.failed.message": "{message}", + "commands.openWorkspace.projectRequired.title": "没有打开的项目", + "commands.openWorkspace.projectRequired.message": "请先打开一个项目。", "commands.closeInstance.label": "关闭标签页", "commands.closeInstance.description": "关闭当前顶层标签页", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index 6a29f2029..119bad8f7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -167,6 +167,15 @@ export const instanceMessages = { "instanceShell.filesShell.search.ariaLabel": "筛选此文件夹中的文件", "instanceShell.filesShell.search.empty": "没有匹配的文件。", "instanceShell.filesShell.actions.copyPath": "复制路径", + "instanceShell.filesShell.actions.more": "{name}的更多操作", + "instanceShell.filesShell.actions.openDefault": "使用默认应用打开", + "instanceShell.filesShell.actions.editDefault": "使用默认应用编辑", + "instanceShell.filesShell.actions.chooseApplication": "打开方式...", + "instanceShell.filesShell.actions.showInFolder": "在文件夹中显示", + "instanceShell.filesShell.actions.openFolder": "在文件管理器中打开", + "instanceShell.filesShell.actions.openTerminal": "在此处打开终端", + "instanceShell.filesShell.actions.openEditor": "在{editor}中打开", + "instanceShell.filesShell.toast.openError": "无法打开:{message}", "instanceShell.filesShell.toast.copyPathSuccess": "路径已复制", "instanceShell.filesShell.toast.copyPathError": "无法复制路径", "instanceShell.filesShell.previewMarkdown": "Markdown 预览", diff --git a/packages/ui/src/lib/workspace-open.ts b/packages/ui/src/lib/workspace-open.ts new file mode 100644 index 000000000..e069daea0 --- /dev/null +++ b/packages/ui/src/lib/workspace-open.ts @@ -0,0 +1,40 @@ +import { isDesktopHost, isLocalWindow, isTauriHost } from "./runtime-env" + +export type WorkspaceOpenTarget = "default" | "reveal" | "terminal" | "editor" +export type WorkspaceEditor = "vscode" | "cursor" | "zed" | "vscodium" + +export interface WorkspaceOpenRequest { + target: WorkspaceOpenTarget + instanceId: string + worktreeSlug: string + path?: string + editor?: WorkspaceEditor +} + +export function canOpenWorkspacePaths(): boolean { + return isDesktopHost() && isLocalWindow() +} + +export async function openWorkspacePath(request: WorkspaceOpenRequest): Promise { + if (!canOpenWorkspacePaths()) throw new Error("Native workspace actions are unavailable") + + if (isTauriHost()) { + const invoke = window.__TAURI__?.core?.invoke + if (!invoke) throw new Error("Tauri bridge is unavailable") + await invoke("open_workspace_target", { ...request }) + return + } + + const openTarget = window.electronAPI?.openWorkspaceTarget + if (!openTarget) throw new Error("Electron bridge is unavailable") + await openTarget(request) +} + +export async function setWorkspaceMenuEnabled(enabled: boolean): Promise { + if (!isDesktopHost() || !isLocalWindow()) return + if (isTauriHost()) { + await window.__TAURI__?.core?.invoke("set_workspace_menu_enabled", { enabled }) + return + } + await window.electronAPI?.setWorkspaceMenuEnabled?.(enabled) +} diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index 612bf22f4..e8b8f8f34 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -415,6 +415,10 @@ transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease; } +.file-list-item .action-overflow-trigger.file-row-action-menu { + display: inline-flex; +} + .git-change-row-action:hover { background-color: var(--surface-hover); border-color: var(--border-base); diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index e4c3a6ab6..b922c343b 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -35,6 +35,15 @@ declare global { restartCli?: () => Promise openDialog?: (options: ElectronDialogOptions) => Promise getDirectoryPaths?: (paths: string[]) => Promise + openWorkspaceTarget?: (payload: { + target: "default" | "reveal" | "terminal" | "editor" + instanceId: string + worktreeSlug: string + path?: string + editor?: "vscode" | "cursor" | "zed" | "vscodium" + }) => Promise<{ ok: true }> + setWorkspaceMenuEnabled?: (enabled: boolean) => Promise<{ ok: true }> + onMenuAction?: (callback: (action: string) => void) => () => void getPathForFile?: (file: File) => string | null requestMicrophoneAccess?: () => Promise<{ granted: boolean }> setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> From d591118edd02775e5ed3b8576a48997d92041cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 23:23:13 +0200 Subject: [PATCH 2/2] fix(files): simplify directory actions Remove per-editor launch entries from directory row overflow menus while retaining file-manager, terminal, and copy-path actions. Project-level editor actions remain available from the command palette and native File menu. Validated with the UI and Electron typechecks. --- .../instance/shell/right-panel/tabs/FilesTab.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index 65f07f58e..b1c96f0e5 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -1,7 +1,7 @@ import { For, Show, Suspense, createEffect, createMemo, createSignal, lazy, type Accessor, type Component, type JSX } from "solid-js" import type { FileNode } from "@opencode-ai/sdk/v2/client" -import { Code2, Copy, ExternalLink, FolderOpen, RefreshCw, Save, Search, TerminalSquare, WrapText } from "lucide-solid" +import { Copy, ExternalLink, FolderOpen, RefreshCw, Save, Search, TerminalSquare, WrapText } from "lucide-solid" import SplitFilePanel from "../components/SplitFilePanel" import { Markdown } from "../../../../markdown" @@ -9,7 +9,7 @@ import { copyToClipboard } from "../../../../../lib/clipboard" import { showToastNotification } from "../../../../../lib/notifications" import { useTheme } from "../../../../../lib/theme" import ActionOverflowMenu, { type ActionOverflowMenuItem } from "../../../../action-overflow-menu" -import { canOpenWorkspacePaths, openWorkspacePath, type WorkspaceEditor } from "../../../../../lib/workspace-open" +import { canOpenWorkspacePaths, openWorkspacePath } from "../../../../../lib/workspace-open" const LazyMonacoFileViewer = lazy(() => import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })), @@ -141,9 +141,8 @@ const FilesTab: Component = (props) => { } const handleNativeOpen = async ( - target: "default" | "reveal" | "terminal" | "editor", + target: "default" | "reveal" | "terminal", path: string, - editor?: WorkspaceEditor, ) => { try { await openWorkspacePath({ @@ -151,7 +150,6 @@ const FilesTab: Component = (props) => { instanceId: props.instanceId, worktreeSlug: props.worktreeSlug(), path, - editor, }) } catch (error) { showToastNotification({ @@ -184,14 +182,6 @@ const FilesTab: Component = (props) => { onSelect: () => handleNativeOpen("terminal", item.path), }, ) - for (const [editor, name] of [["vscode", "VS Code"], ["cursor", "Cursor"], ["zed", "Zed"], ["vscodium", "VSCodium"]] as const) { - items.push({ - key: `open-editor-${editor}`, - label: props.t("instanceShell.filesShell.actions.openEditor", { editor: name }), - icon: , - onSelect: () => handleNativeOpen("editor", item.path, editor), - }) - } } else { if (isWindowsDesktop) { items.push({