mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00: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
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Block a user