дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
6d59cf377c
коміт
6bcf99778a
11 files changed
+276
-32
No files matched your search
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача