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
Цей коміт міститься в:
l0ng-ai committed 2026-10-27 21:43:36 +08:00
1 джерело 6d59cf377c
коміт 6bcf99778a
11 files changed
+276 -32

No files matched your search

+20
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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;