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
This commit is contained in:
l0ng-ai
2026-10-27 21:43:36 +08:00
parent 6d59cf377c
commit 6bcf99778a
11 changed files with 276 additions and 32 deletions
+1 -3
View File
@@ -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)
}
+20 -7
View File
@@ -213,6 +213,8 @@ pub struct DbManager {
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
history: Arc<HistoryStore>,
/// 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<HashMap<String, String>>,
/// 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<SshManager>) -> 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.
+6 -1
View File
@@ -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"),
}
+10
View File
@@ -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"]);
}
+13 -9
View File
@@ -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/<tool>` 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<PathBuf> {
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);
}
+48
View File
@@ -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
+20
View File
@@ -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"]);
});
});
+18 -2
View File
@@ -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 (
+60 -6
View File
@@ -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<RecentState>(() => ({
}));
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<RecentProject, "path" | "name">):
});
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<RecentProject, "path" | "name">):
}
}
async function openRemote(
project: Pick<RecentProject, "path" | "name">,
remote: { sshConfig: string; path: string },
): Promise<boolean> {
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[] = [];
+8 -4
View File
@@ -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);
+72
View File
@@ -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://<configuration id>/<absolute path>`, 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<RecentRemote, "sshConfig" | "path">, b: Pick<RecentRemote, "sshConfig" | "path">) =>
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;