From 6bcf99778a0dc13ec50e76629f52beee6e80bd83 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:43:36 +0800 Subject: [PATCH] fix(remote): Reopen and list recent remote projects; missing rustup components and spelling of references - Remote projects are remembered as ssh:// locations: the last session and a reloaded window reconnect, and the welcome screen lists them - A rustup proxy without its component counts as a missing tool (install hint) rather than a server crashing on every restart - Typo checks the names declarations introduce, not path segments and calls; more programming terms in the word list - The query history records statements in execution order --- crates/ide-db/src/history.rs | 4 +- crates/ide-db/src/lib.rs | 27 +++++--- crates/ide-language/src/syntax/literals.rs | 7 ++- crates/ide-language/src/syntax/tests.rs | 10 +++ crates/ide-lsp/src/launch.rs | 22 ++++--- crates/ide-spell/src/dict/software.txt | 48 +++++++++++++++ src/features/remote-dev/remoteDev.test.ts | 20 ++++++ src/features/welcome/RecentList.tsx | 20 +++++- src/features/welcome/recent.ts | 66 ++++++++++++++++++-- src/features/welcome/session.ts | 12 ++-- src/platform/remote.ts | 72 ++++++++++++++++++++++ 11 files changed, 276 insertions(+), 32 deletions(-) diff --git a/crates/ide-db/src/history.rs b/crates/ide-db/src/history.rs index cf88b970..21213d3c 100644 --- a/crates/ide-db/src/history.rs +++ b/crates/ide-db/src/history.rs @@ -52,9 +52,7 @@ impl HistoryStore { let mut all = self.read(); let entries = all.entry(self.project.clone()).or_default().entry(source.to_owned()).or_default(); entries.retain(|e| e.text != text); - // By time, newest first: statements are recorded off the async threads, in any order. - let at = entries.iter().position(|e| e.time <= time).unwrap_or(entries.len()); - entries.insert(at, HistoryEntry { text: text.to_owned(), time }); + entries.insert(0, HistoryEntry { text: text.to_owned(), time }); entries.truncate(MAX_ENTRIES); self.write(&all) } diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index 8fff7d06..806499fd 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -213,6 +213,8 @@ pub struct DbManager { ssh: Arc, saved_passwords: SecretStore, history: Arc, + /// Records executed statements in the history off the async threads, in order. + recorder: std::sync::mpsc::Sender<(String, String, f64)>, /// Passwords entered this session (not saved, or not yet), by credential key. session_passwords: Mutex>, /// Manual-commit data sources, each with the connection of its open @@ -319,9 +321,25 @@ impl DbManager { /// `passwords` / `history` are the app-wide files of saved passwords /// ([`SecretStore`]) and query history ([`HistoryStore`]). pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc) -> Self { + let history = Arc::new(HistoryStore::new(history, &root)); + let (recorder, statements) = std::sync::mpsc::channel::<(String, String, f64)>(); + { + let history = history.clone(); + let spawned = std::thread::Builder::new().name("openidea-db-history".into()).spawn(move || { + for (source, text, time) in statements { + if let Err(e) = history.add(&source, &text, time) { + tracing::warn!("{}", e.message); + } + } + }); + if let Err(e) = spawned { + tracing::warn!("query history recorder: {e}"); + } + } Self { saved_passwords: SecretStore::new(passwords, &root), - history: Arc::new(HistoryStore::new(history, &root)), + history, + recorder, root, pools: Mutex::default(), tunnels: Mutex::default(), @@ -915,15 +933,10 @@ impl DbManager { } fn remember_statement(&self, id: &str, statement: &str) { - let (history, source, text) = (self.history.clone(), split_target(id).0.to_owned(), statement.to_owned()); let time = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0.0, |d| d.as_secs_f64() * 1000.0); - tokio::task::spawn_blocking(move || { - if let Err(e) = history.add(&source, &text, time) { - tracing::warn!("{}", e.message); - } - }); + let _ = self.recorder.send((split_target(id).0.to_owned(), statement.to_owned(), time)); } /// The query history of data source `id`, newest first. diff --git a/crates/ide-language/src/syntax/literals.rs b/crates/ide-language/src/syntax/literals.rs index 4a784d76..5914a9c1 100644 --- a/crates/ide-language/src/syntax/literals.rs +++ b/crates/ide-language/src/syntax/literals.rs @@ -84,8 +84,13 @@ fn is_name_leaf(kind: &str) -> bool { /// `name = …` assignments) rather than a use of one. fn declares(cursor: &TreeCursor<'_>, node: &Node<'_>) -> bool { let parent = node.parent().map(|p| p.kind()).unwrap_or_default(); + // Paths and calls name what is declared elsewhere (`use a::b::name`, `a.name()`). + let reference = ["scoped", "qualified", "invocation", "member_access", "use_", "import", "call"] + .iter() + .any(|k| parent.contains(k)); match cursor.field_name() { - Some("name" | "pattern" | "declarator") => true, + Some("name") => !reference, + Some("pattern" | "declarator") => true, Some("left") => parent.contains("assignment") || parent.contains("declarator"), _ => parent.contains("parameter"), } diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index 4f4be704..fb344abf 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -1310,4 +1310,14 @@ fn spelling_regions_cover_declared_names_only() { .map(|r| &py[r.start..r.end]) .collect(); assert_eq!(names, ["load_config", "path", "retries", "total"]); + // Names in paths and calls are uses. + let rs = "use crate::ui::keymap;\nfn run() { let n = std::ffi::OsStr::new(\"x\"); a::b::millis(); }\n"; + let names: Vec<&str> = syntax() + .spelling_regions(&lang("rust"), rs) + .unwrap() + .iter() + .filter(|r| r.kind == SpellingKind::Name) + .map(|r| &rs[r.start..r.end]) + .collect(); + assert_eq!(names, ["run", "n"]); } diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index b42273a8..232eec69 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -74,7 +74,7 @@ impl Launcher for ProcessLauncher { let search = server_search_path(config); let binary = which::which_in(&config.command, Some(&search), root) .ok() - .map(|b| resolve_rustup_proxy(&config.command, b)); + .and_then(|b| resolve_rustup_proxy(&config.command, b)); let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); if let Some(addr) = &config.connect { @@ -109,11 +109,12 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString { /// minimal environment. /// `~/.cargo/bin/` is usually a rustup proxy that fails when the active /// toolchain lacks the component (common for rust-analyzer). In that case use -/// the newest installed toolchain that ships the binary. -fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf { +/// the newest installed toolchain that ships the binary; with none, the tool is +/// missing (an install hint, not a server that crashes on every restart). +fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option { let is_proxy = binary.parent().is_some_and(|d| d.ends_with(".cargo/bin")); if !is_proxy || command.contains('/') { - return binary; + return Some(binary); } let works = ide_core::process::no_window(&mut std::process::Command::new(&binary)) .arg("--version") @@ -123,7 +124,7 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf { .status() .is_ok_and(|s| s.success()); if works { - return binary; + return Some(binary); } let toolchains = std::env::var_os("RUSTUP_HOME") .map(PathBuf::from) @@ -143,9 +144,12 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf { match candidates.pop() { Some((_, path)) => { tracing::info!("using {} instead of the rustup proxy", path.display()); - path + Some(path) + } + None => { + tracing::info!("{} is a rustup proxy without the component", binary.display()); + None } - None => binary, } } @@ -286,7 +290,7 @@ mod proxy_tests { #[test] fn non_proxy_binaries_are_kept() { let p = PathBuf::from("/usr/bin/true"); - assert_eq!(resolve_rustup_proxy("true", p.clone()), p); + assert_eq!(resolve_rustup_proxy("true", p.clone()), Some(p)); } /// Exercises the real machine setup when a broken rustup proxy is present. @@ -295,7 +299,7 @@ mod proxy_tests { fn broken_rustup_proxy_falls_back_to_a_toolchain() { let home = PathBuf::from(std::env::var_os("HOME").unwrap()); let proxy = home.join(".cargo/bin/rust-analyzer"); - let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()); + let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()).unwrap(); eprintln!("resolved: {}", resolved.display()); assert_ne!(resolved, proxy); } diff --git a/crates/ide-spell/src/dict/software.txt b/crates/ide-spell/src/dict/software.txt index b67a860c..478f3ddb 100644 --- a/crates/ide-spell/src/dict/software.txt +++ b/crates/ide-spell/src/dict/software.txt @@ -503,3 +503,51 @@ webhook webhooks websocket websockets +# Systems, graphics and terminal code +ascii +bools +clippy +cmd +cmds +dep +dst +ffi +gif +gpui +hsl +isize +jpeg +jpg +keybinding +keybindings +keymap +keymaps +lang +langs +lifecycle +micros +millis +nanos +opengl +otf +pty +ptys +realloc +refcount +refcounted +rgb +rgba +rustup +rwlock +semaphore +serde +subdir +subdirs +tokio +ttf +tty +udp +vulkan +wgpu +woff +woff2 diff --git a/src/features/remote-dev/remoteDev.test.ts b/src/features/remote-dev/remoteDev.test.ts index 791e2019..49e0ecf1 100644 --- a/src/features/remote-dev/remoteDev.test.ts +++ b/src/features/remote-dev/remoteDev.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { RemoteEntry } from "@/platform/ipc"; +import { forgetRemote, parseRemoteLocation, recentRemotes, rememberRemote, remoteLocation } from "@/platform/remote"; import { browsableFolders, listedDirectory, remoteParent } from "./model"; const entry = (path: string, isDir = true): RemoteEntry => ({ @@ -37,3 +38,22 @@ describe("remote folder browser", () => { expect(listedDirectory("/", [])).toBe("/"); }); }); + +describe("remote project locations", () => { + it("round-trip the configuration id and the path", () => { + const loc = remoteLocation("mac mini/1", "/Users/me/repo"); + expect(loc).toBe("ssh://mac%20mini%2F1/Users/me/repo"); + expect(parseRemoteLocation(loc)).toEqual({ sshConfig: "mac mini/1", path: "/Users/me/repo" }); + expect(parseRemoteLocation("/Users/me/repo")).toBeNull(); + }); + + it("remembers recent remote projects most recent first, once each", () => { + localStorage.clear(); + rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 1 }); + rememberRemote({ sshConfig: "b", path: "/q", name: "q", host: "me@b", openedAtMs: 2 }); + rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 3 }); + expect(recentRemotes().map((r) => r.path)).toEqual(["/p", "/q"]); + forgetRemote("a", "/p"); + expect(recentRemotes().map((r) => r.path)).toEqual(["/q"]); + }); +}); diff --git a/src/features/welcome/RecentList.tsx b/src/features/welcome/RecentList.tsx index 2c3c158d..c0dd2d45 100644 --- a/src/features/welcome/RecentList.tsx +++ b/src/features/welcome/RecentList.tsx @@ -1,6 +1,7 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; import clsx from "clsx"; import type { RecentProject } from "@/platform/ipc"; +import { parseRemoteLocation } from "@/platform/remote"; import { HighlightedText, Icon, @@ -38,7 +39,9 @@ export function ProjectBadge({ name, className }: { name: string; className?: st } export function recentMenu(project: RecentProject, onOpened?: () => void): MenuItem[] { - return [ + // A remote project opens in this window only, and has no local folder. + const local = parseRemoteLocation(project.path) === null; + const items: MenuItem[] = [ { id: "open", label: "Open", @@ -68,6 +71,9 @@ export function recentMenu(project: RecentProject, onOpened?: () => void): MenuI run: () => void askRemoveRecent(project), }, ]; + return local + ? items + : items.filter((i) => !("id" in i && i.id === "reveal") && !("action" in i && i.action === "file.openInNewWindow")); } function relativeTime(ms: number | null): string | null { @@ -82,7 +88,17 @@ function relativeTime(ms: number | null): string | null { return new Date(ms).toLocaleDateString(); } -function RecentRow({ m, active, compact, onOpened }: { m: RecentMatch; active: boolean; compact?: boolean; onOpened?: () => void }) { +function RecentRow({ + m, + active, + compact, + onOpened, +}: { + m: RecentMatch; + active: boolean; + compact?: boolean; + onOpened?: () => void; +}) { const opening = useRecent((s) => s.opening === m.project.path); const when = relativeTime(m.project.openedAtMs); return ( diff --git a/src/features/welcome/recent.ts b/src/features/welcome/recent.ts index 03f30320..16be3263 100644 --- a/src/features/welcome/recent.ts +++ b/src/features/welcome/recent.ts @@ -3,6 +3,8 @@ import { create } from "zustand"; import { executeAction, getAction } from "@/platform/actions"; import { commands, IpcError, isTauri, type RecentProject } from "@/platform/ipc"; import { openProject, useProject } from "@/platform/project"; +import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remoteLocation } from "@/platform/remote"; +import { tx } from "@/platform/i18n"; import { fuzzyMatch } from "@/ui"; import { notifications } from "@/workbench/contributions"; @@ -24,7 +26,12 @@ export const useRecent = create(() => ({ })); const SAMPLE: RecentProject[] = [ - { path: "/Users/dev/dev/acme-api", name: "acme-api", openedAtMs: Date.now() - 3_600_000, branch: "feature/order-paging" }, + { + path: "/Users/dev/dev/acme-api", + name: "acme-api", + openedAtMs: Date.now() - 3_600_000, + branch: "feature/order-paging", + }, { path: "/Users/dev/dev/openidea", name: "openidea", openedAtMs: Date.now() - 86_400_000, branch: "main" }, { path: "/Users/dev/oss/tokio", name: "tokio", openedAtMs: Date.now() - 5 * 86_400_000, branch: "master" }, { path: "/Users/dev/scratch/py-notes", name: "py-notes", openedAtMs: null, branch: null }, @@ -38,20 +45,43 @@ export async function loadRecent() { const [projects, info, home] = await Promise.all([ commands.recentProjects().catch(() => [] as RecentProject[]), commands.appInfo().catch(() => null), - import("@tauri-apps/api/path") - .then((m) => m.homeDir()) - .catch(() => null), + import("@tauri-apps/api/path").then((m) => m.homeDir()).catch(() => null), ]); useRecent.setState({ - projects, + projects: withRemotes(projects), loaded: true, version: info?.version ?? useRecent.getState().version, home: home ? home.replace(/\/+$/, "") : useRecent.getState().home, }); } +/** The recent remote projects (Remote Development) among the local ones, most recent first. */ +function withRemotes(local: RecentProject[]): RecentProject[] { + const remote: RecentProject[] = recentRemotes().map((r) => ({ + path: remoteLocation(r.sshConfig, r.path), + name: r.name, + openedAtMs: r.openedAtMs, + branch: null, + })); + if (remote.length === 0) return local; + return [...local, ...remote].sort((a, b) => (b.openedAtMs ?? 0) - (a.openedAtMs ?? 0)); +} + +/** `host:path` of a remote project location, else `null`. */ +export function remoteDisplay(location: string): string | null { + const r = parseRemoteLocation(location); + if (!r) return null; + const host = recentRemotes().find((x) => x.sshConfig === r.sshConfig && x.path === r.path)?.host || r.sshConfig; + return `${host}:${r.path}`; +} + export async function removeRecent(path: string) { useRecent.setState((s) => ({ projects: s.projects.filter((p) => p.path !== path) })); + const remote = parseRemoteLocation(path); + if (remote) { + forgetRemote(remote.sshConfig, remote.path); + return; + } if (!isTauri()) return; const r = await commands.removeRecentProject(path); if (r.status === "error") @@ -68,6 +98,8 @@ export async function openRecent(project: Pick): }); return false; } + const remote = parseRemoteLocation(project.path); + if (remote) return openRemote(project, remote); const current = useProject.getState().project; if (current?.root === project.path) return true; // With a project open, the app feature applies "Open project in" (new / this window / ask). @@ -95,6 +127,28 @@ export async function openRecent(project: Pick): } } +async function openRemote( + project: Pick, + remote: { sshConfig: string; path: string }, +): Promise { + useRecent.setState({ opening: project.path }); + try { + await connectRemote(remote.sshConfig, remote.path); + return true; + } catch (e) { + notifications().notify({ + level: "error", + title: tx`Cannot open ${project.name}`, + body: e instanceof Error ? e.message : String(e), + key: `welcome.open:${project.path}`, + actions: [{ label: "Remove from Recent Projects", run: () => void removeRecent(project.path) }], + }); + return false; + } finally { + useRecent.setState({ opening: null }); + } +} + // --------------------------------------------------------------------------- // Pure helpers @@ -125,7 +179,7 @@ export function filterRecent(projects: readonly RecentProject[], query: string, project, nameMatch: null as number[] | null, pathMatch: null as number[] | null, - displayPath: tildify(project.path, home), + displayPath: remoteDisplay(project.path) ?? tildify(project.path, home), })); if (!q) return all; const byName: RecentMatch[] = []; diff --git a/src/features/welcome/session.ts b/src/features/welcome/session.ts index 6035159a..cb6a1356 100644 --- a/src/features/welcome/session.ts +++ b/src/features/welcome/session.ts @@ -7,6 +7,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { create } from "zustand"; import { commands, isTauri, type Settings } from "@/platform/ipc"; import { openProject, startupLaunchHandled, useProject } from "@/platform/project"; +import { connectRemote, parseRemoteLocation, projectLocation } from "@/platform/remote"; import { notifications } from "@/workbench/contributions"; const SESSION_KEY = "openidea.session.openProject"; @@ -60,14 +61,15 @@ export function reopenEnabled(settings: Settings | null | undefined, pref: boole /** Keeps the remembered project in sync: set while a project is open, cleared on explicit close. */ export function trackSession(): () => void { - const root = useProject.getState().project?.root; + const root = projectLocation(useProject.getState().project?.root); if (root) { write(SESSION_KEY, root); windowProject(root); } return useProject.subscribe((s, prev) => { - const now = s.project?.root ?? null; - const before = prev.project?.root ?? null; + // A remote project is remembered by its location (`ssh://…`), reopened over SSH. + const now = projectLocation(s.project?.root); + const before = projectLocation(prev.project?.root); if (now === before) return; if (now) write(SESSION_KEY, now); else if (before && !useSession.getState().reopening) write(SESSION_KEY, null); @@ -114,7 +116,9 @@ async function reopen(path: string) { const name = path.slice(path.lastIndexOf("/") + 1) || path; useSession.setState({ reopening: path }); try { - await openProject(path); + const remote = parseRemoteLocation(path); + if (remote) await connectRemote(remote.sshConfig, remote.path); + else await openProject(path); } catch (e) { write(SESSION_KEY, null); windowProject(null); diff --git a/src/platform/remote.ts b/src/platform/remote.ts index a42b2cd1..d4d13679 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -103,6 +103,13 @@ export async function connectRemote(sshConfig: string, path: string) { const r = await call(commands.remoteConnect(sshConfig, path)); enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands }); useProject.setState({ project: r.project, settings: r.project.settings }); + rememberRemote({ + sshConfig: r.sshConfig, + path: r.root, + name: r.project.name, + host: r.host, + openedAtMs: Date.now(), + }); return r; } finally { useProject.setState({ opening: false }); @@ -116,6 +123,71 @@ export async function disconnectRemote() { useProject.setState({ project: null }); } +// --------------------------------------------------------------------------- +// Remote project locations: `ssh:///`, what the +// session and the recent projects remember for a remote window. + +const SCHEME = "ssh://"; + +export function remoteLocation(sshConfig: string, path: string): string { + return `${SCHEME}${encodeURIComponent(sshConfig)}${path.startsWith("/") ? "" : "/"}${path}`; +} + +export function parseRemoteLocation(location: string): { sshConfig: string; path: string } | null { + if (!location.startsWith(SCHEME)) return null; + const rest = location.slice(SCHEME.length); + const slash = rest.indexOf("/"); + if (slash <= 0) return null; + return { sshConfig: decodeURIComponent(rest.slice(0, slash)), path: rest.slice(slash) }; +} + +/** The location of this window's project: remote, else its local root. */ +export function projectLocation(root: string | null | undefined): string | null { + if (!root) return null; + const session = useRemote.getState().session; + return session && session.root === root ? remoteLocation(session.sshConfig, root) : root; +} + +export interface RecentRemote { + sshConfig: string; + path: string; + name: string; + /** `user@host:port` when it was opened. */ + host: string; + openedAtMs: number; +} + +const RECENT_KEY = "openidea.remote.recent"; +const RECENT_MAX = 20; + +export function recentRemotes(): RecentRemote[] { + try { + const list = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]") as unknown; + return Array.isArray(list) ? (list as RecentRemote[]).filter((r) => r && r.sshConfig && r.path) : []; + } catch { + return []; + } +} + +function saveRemotes(list: RecentRemote[]) { + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX))); + } catch { + /* storage unavailable */ + } +} + +const sameRemote = (a: Pick, b: Pick) => + a.sshConfig === b.sshConfig && a.path === b.path; + +export function rememberRemote(entry: RecentRemote) { + saveRemotes([entry, ...recentRemotes().filter((r) => !sameRemote(r, entry))]); +} + +export function forgetRemote(sshConfig: string, path: string) { + saveRemotes(recentRemotes().filter((r) => !sameRemote(r, { sshConfig, path }))); +} + // Any way of leaving the remote project (Close Project, opening a local one) ends remote routing. useProject.subscribe((s) => { const session = useRemote.getState().session;