From f81d3d3b3e09254973d8806d5eb2f8a6032054c3 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 28 Oct 2026 11:22:48 -0700 Subject: [PATCH] fix: polish desktop development and workspace controls Assisted-by: gpt-6.1-terra --- scripts/ci-changes.mjs | 2 +- scripts/ci-changes.test.mjs | 1 + scripts/desktop.js | 6 ++ scripts/dev-slot.mjs | 111 ++++++++++++++++++---- src/commands/app.rs | 8 +- src/commands/up.rs | 12 +++ src/editors.rs | 97 +++++++++++++++++++ ui/messages/ar.json | 4 +- ui/messages/en.json | 4 +- ui/messages/es.json | 4 +- ui/messages/fa.json | 4 +- ui/messages/hi.json | 4 +- ui/messages/ja.json | 4 +- ui/messages/ko.json | 4 +- ui/messages/pl.json | 4 +- ui/messages/zh-CN.json | 4 +- ui/src/App.tsx | 4 +- ui/src/api.ts | 3 + ui/src/app.css | 4 +- ui/src/components/ChatPanel.tsx | 3 +- ui/src/components/ClosableTab.tsx | 2 +- ui/src/components/DetailDrawer.tsx | 2 +- ui/src/components/ExperimentRunBanner.tsx | 81 ++++++++++------ ui/src/components/FileViewer.tsx | 6 +- ui/src/components/ProjectInfoCard.tsx | 4 +- ui/src/components/TabStrip.tsx | 2 +- ui/src/components/ui/Tooltip.tsx | 1 + ui/src/components/useFileManagerLabel.ts | 14 +++ ui/src/queries/settings.ts | 6 ++ ui/tests/projectDelete.test.mjs | 1 + 30 files changed, 333 insertions(+), 73 deletions(-) create mode 100644 scripts/desktop.js create mode 100644 ui/src/components/useFileManagerLabel.ts diff --git a/scripts/ci-changes.mjs b/scripts/ci-changes.mjs index 6a4f71d55..277b572a5 100644 --- a/scripts/ci-changes.mjs +++ b/scripts/ci-changes.mjs @@ -22,7 +22,7 @@ export function classifyChanges(paths) { else if (path.startsWith('windows/')) checks.windows = true else if (path.startsWith('linux/') || /scripts\/(build|smoke-test)-linux/.test(path)) checks.linux = true else if (path.startsWith('macos/') || /scripts\/(build|package)-macos/.test(path)) Object.assign(checks, { rust: true, macos: true }) - else if (/^scripts\/dev-slot(\.test)?\.mjs$/.test(path)) checks.dev_slot = true + else if (/^scripts\/(?:dev-slot(?:\.test)?\.mjs|desktop\.js)$/.test(path)) checks.dev_slot = true else if (/^scripts\/(test_orx_chart|orx_chart)\.py$/.test(path)) checks.chart = true else if (/^scripts\/(test_release_notes|release_notes)\.py$/.test(path)) checks.release_notes = true else return all() diff --git a/scripts/ci-changes.test.mjs b/scripts/ci-changes.test.mjs index 4bde88379..b1f00c0e1 100644 --- a/scripts/ci-changes.test.mjs +++ b/scripts/ci-changes.test.mjs @@ -23,6 +23,7 @@ test('platform files select corresponding checks', () => { assert.deepEqual(enabled(['macos/Info.plist']), ['rust', 'macos']) }) test('helper tests are independent and combine with other changes', () => { + assert.deepEqual(enabled(['scripts/desktop.js']), ['dev_slot']) assert.deepEqual(enabled(['scripts/dev-slot.mjs', 'scripts/test_orx_chart.py', 'scripts/release_notes.py', 'ui/package.json']), ['frontend', 'dev_slot', 'chart', 'release_notes']) }) test('docs skip builds, workflow changes and unknown files select all', () => { diff --git a/scripts/desktop.js b/scripts/desktop.js new file mode 100644 index 000000000..4a6b1e1ba --- /dev/null +++ b/scripts/desktop.js @@ -0,0 +1,6 @@ +import('./dev-slot.mjs') + .then(({ desktop }) => desktop()) + .catch((error) => { + console.error(`desktop: ${error.message}`) + process.exitCode = 1 + }) diff --git a/scripts/dev-slot.mjs b/scripts/dev-slot.mjs index d608b370e..319084a78 100755 --- a/scripts/dev-slot.mjs +++ b/scripts/dev-slot.mjs @@ -608,7 +608,7 @@ function saveState(slotPaths, state) { atomicWriteJson(slotPaths.statePath, state) } -async function startUnlocked(info, dbMode, openBrowser, desktop) { +async function startUnlocked(info, dbMode, openBrowser) { const reservation = await reserveSlot(info, dbMode) const { slotPaths } = reservation const state = reservation.state @@ -668,22 +668,6 @@ async function startUnlocked(info, dbMode, openBrowser, desktop) { state.phase = 'running' saveState(slotPaths, state) - if (desktop) { - execFileSync('cargo', ['build', '--manifest-path', info.manifestPath], { - cwd: info.worktreePath, env: { ...process.env, ...slotEnvironment(slotPaths) }, stdio: 'inherit', - }) - const contents = path.join(slotPaths.cacheDir, 'OpenResearch Dev.app', 'Contents') - mkdirSync(path.join(contents, 'MacOS'), { recursive: true }) - const executable = path.join(contents, 'MacOS', 'OpenResearch Dev') - cpSync(path.join(slotPaths.cargoTargetDir, 'debug', 'orx'), executable) - writeFileSync(path.join(contents, 'Info.plist'), `\n\nCFBundleExecutableOpenResearch DevCFBundleIdentifierorg.alphaxiv.openresearch.dev.slot${slotPaths.slot}CFBundleNameOpenResearch DevCFBundlePackageTypeAPPL\n`) - const child = spawn(executable, ['--dev-desktop', String(slotPaths.uiPort)], { - detached: true, stdio: 'ignore', env: { ...process.env, ...slotEnvironment(slotPaths) }, - }) - child.on('error', (error) => console.error(`Could not open the desktop app: ${error.message}`)) - child.unref() - } - if (openBrowser) { const url = `http://localhost:${slotPaths.uiPort}/` const opener = process.platform === 'darwin' @@ -704,15 +688,101 @@ async function startUnlocked(info, dbMode, openBrowser, desktop) { console.log(` State: ${slotPaths.statePath}`) } -async function start(info, dbMode, openBrowser, desktop) { +async function start(info, dbMode, openBrowser) { const release = await acquireLifecycleLock(info) try { - await startUnlocked(info, dbMode, openBrowser, desktop) + await startUnlocked(info, dbMode, openBrowser) } finally { await release() } } +export async function desktop(worktree = process.cwd(), requestedMode = null) { + if (process.platform !== 'darwin') throw new Error('The development desktop app requires macOS') + const info = worktreeInfo(worktree) + let child = null + let exit = null + let result = null + let record = null + let started = false + let interrupted = false + const interrupt = () => { + interrupted = true + if (child?.pid && processGroupExists(child.pid)) { + try { process.kill(-child.pid, 'SIGTERM') } catch (error) { if (error.code !== 'ESRCH') throw error } + } + } + process.on('SIGINT', interrupt) + process.on('SIGTERM', interrupt) + try { + const release = await acquireLifecycleLock(info) + try { + const previous = reservationFor(info) + if (previous?.state?.desktopProcess && processGroupExists(previous.state.desktopProcess.pid)) { + if (!supervisorMatches(previous.state.desktopProcess)) throw new Error('The previous desktop process no longer matches its recorded identity') + process.kill(-previous.state.desktopProcess.pid, 'SIGTERM') + await waitFor('the previous desktop app to close', 10_000, () => !processGroupExists(previous.state.desktopProcess.pid)) + } + await stopOwnedProcessesUnlocked(info) + if (interrupted) return + const mode = requestedMode ?? previous?.state?.dbMode ?? 'empty' + started = true + await startUnlocked(info, mode, false) + if (interrupted) return + const { slotPaths, state } = reservationFor(info) + execFileSync('cargo', ['build', '--manifest-path', info.manifestPath], { + cwd: info.worktreePath, env: { ...process.env, ...slotEnvironment(slotPaths) }, stdio: 'inherit', + }) + if (interrupted) return + const contents = path.join(slotPaths.cacheDir, 'OpenResearch Dev.app', 'Contents') + mkdirSync(path.join(contents, 'MacOS'), { recursive: true }) + const executable = path.join(contents, 'MacOS', 'OpenResearch Dev') + cpSync(path.join(slotPaths.cargoTargetDir, 'debug', 'orx'), executable) + writeFileSync(path.join(contents, 'Info.plist'), `\n\nCFBundleExecutableOpenResearch DevCFBundleIdentifierorg.alphaxiv.openresearch.dev.slot${slotPaths.slot}CFBundleNameOpenResearch DevCFBundlePackageTypeAPPL\n`) + const token = randomUUID() + child = spawn(process.execPath, [SCRIPT_PATH, '_supervise', token, executable, '--dev-desktop', String(slotPaths.uiPort)], { + detached: true, stdio: 'inherit', env: { ...process.env, ...slotEnvironment(slotPaths) }, + }) + if (!child.pid) throw new Error('Could not launch the desktop app') + exit = new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + }) + record = { pid: child.pid, token, scriptPath: SCRIPT_PATH } + state.desktopProcess = record + saveState(slotPaths, state) + console.log('Close the desktop window to stop the development servers.') + } finally { + try { + if (started && !record) await stopOwnedProcessesUnlocked(info) + } finally { + await release() + } + } + if (exit) { + result = await exit + } + } finally { + process.removeListener('SIGINT', interrupt) + process.removeListener('SIGTERM', interrupt) + const release = await acquireLifecycleLock(info) + try { + const current = reservationFor(info) + if (current && record && current.state?.desktopProcess?.token === record.token) { + if (!interrupted && result && (result.signal || result.code !== 0)) process.exitCode = 1 + await stopOwnedProcessesUnlocked(info) + const state = readJson(current.slotPaths.statePath) + if (state) { + state.desktopProcess = null + saveState(current.slotPaths, state) + } + } + } finally { + await release() + } + } +} + function reservationFor(info) { const registry = loadRegistry(info) const backend = registry.configurations.find((configuration) => @@ -881,7 +951,8 @@ async function main() { } const options = parseArgs(process.argv.slice(2)) const info = worktreeInfo(options.worktree) - if (options.command === 'start') await start(info, options.db, options.open, options.desktop) + if (options.command === 'start' && options.desktop) await desktop(options.worktree, options.db) + else if (options.command === 'start') await start(info, options.db, options.open) if (options.command === 'status') printStatus(info) if (options.command === 'stop') await stopOwnedProcesses(info) if (options.command === 'cleanup') await cleanup(info) diff --git a/src/commands/app.rs b/src/commands/app.rs index 8e9320fcb..9313862af 100644 --- a/src/commands/app.rs +++ b/src/commands/app.rs @@ -1103,8 +1103,12 @@ mod imp { .. } => { #[cfg(target_os = "macos")] - // AppKit switches Spaces when hiding the app, unlike ordering out its window. - NSApplication::sharedApplication(MainThreadMarker::new().unwrap()).hide(None); + if external_server { + begin_quit(&mut quit, &window, &webview, control_flow); + } else { + // AppKit switches Spaces when hiding the app, unlike ordering out its window. + NSApplication::sharedApplication(MainThreadMarker::new().unwrap()).hide(None); + } #[cfg(not(target_os = "macos"))] begin_quit(&mut quit, &window, &webview, control_flow); } diff --git a/src/commands/up.rs b/src/commands/up.rs index 68919203e..a3cc8dfe9 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -512,6 +512,7 @@ impl ProjectLifecycle { fn router(state: AppState, remote_auth: Option) -> Router { let app = Router::new() .route("/api/health", get(health)) + .route("/api/file-manager", get(file_manager)) .route("/api/onboarding/complete", post(complete_onboarding)) .route("/api/project-path/status", get(project_path_status)) .route("/api/project-path/pick", post(pick_project_folder)) @@ -1012,6 +1013,17 @@ async fn health(State(state): State) -> Json { })) } +async fn file_manager( + Query(query): Query>, +) -> Json { + let locale = query.get("locale").cloned().unwrap_or_default(); + let name = tokio::task::spawn_blocking(move || crate::editors::file_manager_name(&locale)) + .await + .ok() + .flatten(); + Json(json!({ "os": std::env::consts::OS, "name": name })) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CompleteOnboardingReq { diff --git a/src/editors.rs b/src/editors.rs index 88224d134..b607f345c 100644 --- a/src/editors.rs +++ b/src/editors.rs @@ -87,10 +87,107 @@ pub fn reveal_in_file_manager(path: &std::path::Path) -> std::io::Result<()> { spawn_detached(&mut reveal_command(path)) } +/// The directory handler named by the platform, if it can be identified. +pub fn file_manager_name(_locale: &str) -> Option { + #[cfg(target_os = "macos")] + { + None + } + #[cfg(target_os = "windows")] + { + None + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let mut command = Command::new("xdg-mime"); + command.args(["query", "default", "inode/directory"]); + crate::local::shell_env::restore_host_gui_env(&mut command); + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + let id = String::from_utf8(output.stdout).ok()?; + let id = id.trim(); + if id.is_empty() || id.contains(['/', '\\']) { + return None; + } + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + let data_home = std::env::var_os("XDG_DATA_HOME") + .map(std::path::PathBuf::from) + .or_else(|| home.map(|path| path.join(".local/share"))); + let dirs = crate::local::shell_env::host_gui_env() + .into_iter() + .find(|(key, _)| *key == "XDG_DATA_DIRS") + .map(|(_, value)| value) + .unwrap_or_else(|| std::env::var_os("XDG_DATA_DIRS")) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + data_home + .into_iter() + .chain(std::env::split_paths(&dirs)) + .find_map(|path| { + std::fs::read_to_string(path.join("applications").join(id)) + .ok() + .and_then(|entry| desktop_entry_name(&entry, _locale)) + }) + } +} + +#[cfg(any(test, not(any(target_os = "macos", target_os = "windows"))))] +fn desktop_entry_name(entry: &str, locale: &str) -> Option { + let locale = locale.replace('-', "_"); + let language = locale.split('_').next().unwrap_or(&locale); + let mut in_entry = false; + let mut default = None; + let mut localized = None; + for line in entry.lines().map(str::trim) { + if line.starts_with('[') && line.ends_with(']') { + in_entry = line == "[Desktop Entry]"; + } else if in_entry { + let Some((key, name)) = line.split_once('=') else { + continue; + }; + let name = name.trim(); + if name.is_empty() { + continue; + } + if key == "Name" { + default = Some(name); + } else if key == format!("Name[{locale}]") { + return Some(name.to_owned()); + } else if key == format!("Name[{language}]") { + localized = Some(name); + } + } + } + localized.or(default).map(str::to_owned) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn desktop_entry_name_uses_the_application_section() { + assert_eq!( + desktop_entry_name( + "[Other]\nName=Wrong\n[Desktop Entry]\nName=Files\n[Desktop Action New]\nName=New", + "en" + ), + Some("Files".into()) + ); + assert_eq!(desktop_entry_name("[Desktop Entry]\nName=\n", "en"), None); + let entry = + "[Desktop Entry]\nName=Files\nName[zh]=文件\nName[zh_CN]=文件管理器\nName[es]=Archivos"; + assert_eq!( + desktop_entry_name(entry, "zh-CN"), + Some("文件管理器".into()) + ); + assert_eq!(desktop_entry_name(entry, "zh-TW"), Some("文件".into())); + assert_eq!(desktop_entry_name(entry, "es"), Some("Archivos".into())); + assert_eq!(desktop_entry_name(entry, "ja"), Some("Files".into())); + } + fn args(cmd: &Command) -> Vec { cmd.get_args() .map(|a| a.to_string_lossy().into_owned()) diff --git a/ui/messages/ar.json b/ui/messages/ar.json index 99f3edacc..e4e8971b4 100644 --- a/ui/messages/ar.json +++ b/ui/messages/ar.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "مجلد المصدر", "sidebar_pin_project": "تثبيت", "sidebar_unpin_project": "إلغاء التثبيت", - "sidebar_reveal_project": "إظهار في Finder", + "file_manager_finder": "Finder", + "file_manager_explorer": "مستكشف الملفات", + "reveal_in_named_file_manager": "إظهار في {manager}", "sidebar_filter": "تصفية", "sidebar_group_by": "تجميع حسب", "sidebar_one_list": "في قائمة واحدة", diff --git a/ui/messages/en.json b/ui/messages/en.json index 13ad8573a..7c3ee11ca 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "Source folder", "sidebar_pin_project": "Pin", "sidebar_unpin_project": "Unpin", - "sidebar_reveal_project": "Reveal in Finder", + "file_manager_finder": "Finder", + "file_manager_explorer": "File Explorer", + "reveal_in_named_file_manager": "Reveal in {manager}", "sidebar_filter": "Filter", "sidebar_group_by": "Group by", "sidebar_one_list": "In one list", diff --git a/ui/messages/es.json b/ui/messages/es.json index 0fc653fa2..f8c221c94 100644 --- a/ui/messages/es.json +++ b/ui/messages/es.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "Carpeta de origen", "sidebar_pin_project": "Fijar", "sidebar_unpin_project": "Desfijar", - "sidebar_reveal_project": "Mostrar en Finder", + "file_manager_finder": "Finder", + "file_manager_explorer": "Explorador de archivos", + "reveal_in_named_file_manager": "Mostrar en {manager}", "sidebar_filter": "Filtrar", "sidebar_group_by": "Agrupar por", "sidebar_one_list": "En una lista", diff --git a/ui/messages/fa.json b/ui/messages/fa.json index d291e692b..5618514aa 100644 --- a/ui/messages/fa.json +++ b/ui/messages/fa.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "پوشهٔ منبع", "sidebar_pin_project": "سنجاق کردن", "sidebar_unpin_project": "برداشتن سنجاق", - "sidebar_reveal_project": "نمایش در Finder", + "file_manager_finder": "Finder", + "file_manager_explorer": "کاوشگر فایل", + "reveal_in_named_file_manager": "نمایش در {manager}", "sidebar_filter": "فیلتر", "sidebar_group_by": "گروه‌بندی بر اساس", "sidebar_one_list": "در یک فهرست", diff --git a/ui/messages/hi.json b/ui/messages/hi.json index 695a52acf..544599353 100644 --- a/ui/messages/hi.json +++ b/ui/messages/hi.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "स्रोत फ़ोल्डर", "sidebar_pin_project": "पिन करें", "sidebar_unpin_project": "पिन हटाएँ", - "sidebar_reveal_project": "Finder में दिखाएँ", + "file_manager_finder": "Finder", + "file_manager_explorer": "फ़ाइल एक्सप्लोरर", + "reveal_in_named_file_manager": "{manager} में दिखाएँ", "sidebar_filter": "फ़िल्टर", "sidebar_group_by": "समूह बनाएँ", "sidebar_one_list": "एक सूची में", diff --git a/ui/messages/ja.json b/ui/messages/ja.json index 9571b9fd4..2713830ad 100644 --- a/ui/messages/ja.json +++ b/ui/messages/ja.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "ソースフォルダー", "sidebar_pin_project": "ピン留め", "sidebar_unpin_project": "ピン留めを解除", - "sidebar_reveal_project": "Finder で表示", + "file_manager_finder": "Finder", + "file_manager_explorer": "エクスプローラー", + "reveal_in_named_file_manager": "{manager} で表示", "sidebar_filter": "フィルター", "sidebar_group_by": "グループ分け", "sidebar_one_list": "1 つの一覧", diff --git a/ui/messages/ko.json b/ui/messages/ko.json index 908eb81c8..f7caa1ff3 100644 --- a/ui/messages/ko.json +++ b/ui/messages/ko.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "소스 폴더", "sidebar_pin_project": "고정", "sidebar_unpin_project": "고정 해제", - "sidebar_reveal_project": "Finder에서 표시", + "file_manager_finder": "Finder", + "file_manager_explorer": "파일 탐색기", + "reveal_in_named_file_manager": "{manager}에서 표시", "sidebar_filter": "필터", "sidebar_group_by": "그룹화 기준", "sidebar_one_list": "단일 목록", diff --git a/ui/messages/pl.json b/ui/messages/pl.json index 40f9ec573..eb75f9ac2 100644 --- a/ui/messages/pl.json +++ b/ui/messages/pl.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "Folder źródłowy", "sidebar_pin_project": "Przypnij", "sidebar_unpin_project": "Odepnij", - "sidebar_reveal_project": "Pokaż w Finderze", + "file_manager_finder": "Finder", + "file_manager_explorer": "Eksplorator plików", + "reveal_in_named_file_manager": "Pokaż w {manager}", "sidebar_filter": "Filtruj", "sidebar_group_by": "Grupuj według", "sidebar_one_list": "Na jednej liście", diff --git a/ui/messages/zh-CN.json b/ui/messages/zh-CN.json index abf660f25..3457db711 100644 --- a/ui/messages/zh-CN.json +++ b/ui/messages/zh-CN.json @@ -1340,7 +1340,9 @@ "sidebar_source_folder": "源文件夹", "sidebar_pin_project": "置顶", "sidebar_unpin_project": "取消置顶", - "sidebar_reveal_project": "在访达中显示", + "file_manager_finder": "访达", + "file_manager_explorer": "文件资源管理器", + "reveal_in_named_file_manager": "在 {manager} 中显示", "sidebar_filter": "筛选", "sidebar_group_by": "分组方式", "sidebar_one_list": "单一列表", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0522d16bf..03cd3e726 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1908,7 +1908,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo )} {mainView === "chat" && panelOpen && (