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 38fde7ff0..eadd9701b 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, @@ -608,26 +610,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..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,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 { 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 } 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,78 @@ const FilesTab: Component = (props) => { }) } + const handleNativeOpen = async ( + target: "default" | "reveal" | "terminal", + path: string, + ) => { + try { + await openWorkspacePath({ + target, + instanceId: props.instanceId, + worktreeSlug: props.worktreeSlug(), + path, + }) + } 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), + }, + ) + } 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 +287,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 }>