janissary 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +75 -0
- package/README.md +28 -0
- package/agent-names.json +52 -0
- package/bin/janus.mjs +27 -0
- package/chrome-extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
- package/chrome-extension/content-script.js +62 -0
- package/chrome-extension/manifest.json +25 -0
- package/chrome-extension/rules.json +16 -0
- package/dist/acp/index.js +93 -0
- package/dist/acp/loop.js +75 -0
- package/dist/acp/manager.js +126 -0
- package/dist/acp/rate-limit.js +8 -0
- package/dist/acp/runner.js +18 -0
- package/dist/acp/tools.js +27 -0
- package/dist/acp-loop.js +75 -0
- package/dist/acp-manager.js +109 -0
- package/dist/acp-runner.js +18 -0
- package/dist/acp.js +91 -0
- package/dist/agent/commands.js +29 -0
- package/dist/agent/communication-manager.js +71 -0
- package/dist/agent/state.js +58 -0
- package/dist/agent-commands.js +29 -0
- package/dist/agent-communication-manager.js +71 -0
- package/dist/agent-state.js +58 -0
- package/dist/app-themes.js +13 -0
- package/dist/browser/command.js +104 -0
- package/dist/browser/index.js +124 -0
- package/dist/browser/tab-helpers.js +51 -0
- package/dist/browser/tab.js +99 -0
- package/dist/browser-command.js +104 -0
- package/dist/browser-tab-helpers.js +51 -0
- package/dist/browser-tab.js +99 -0
- package/dist/browser.js +124 -0
- package/dist/buffer.js +42 -0
- package/dist/bus.js +52 -0
- package/dist/capture/manager.js +48 -0
- package/dist/capture/router.js +18 -0
- package/dist/capture-manager.js +48 -0
- package/dist/capture-router.js +20 -0
- package/dist/chrome-extension-loader.js +62 -0
- package/dist/cli-args.js +80 -0
- package/dist/command-manager.js +126 -0
- package/dist/command-router.js +14 -0
- package/dist/commands/acp-reset.js +13 -0
- package/dist/commands/acp.js +5 -0
- package/dist/commands/agent.js +5 -0
- package/dist/commands/broadcast.js +22 -0
- package/dist/commands/browser.js +5 -0
- package/dist/commands/clear.js +5 -0
- package/dist/commands/close.js +42 -0
- package/dist/commands/connection.js +5 -0
- package/dist/commands/db.js +5 -0
- package/dist/commands/edit.js +18 -0
- package/dist/commands/files.js +13 -0
- package/dist/commands/hist.js +7 -0
- package/dist/commands/index.js +62 -0
- package/dist/commands/monitor.js +61 -0
- package/dist/commands/msg.js +17 -0
- package/dist/commands/next.js +7 -0
- package/dist/commands/notifications.js +16 -0
- package/dist/commands/notify.js +20 -0
- package/dist/commands/open.js +41 -0
- package/dist/commands/profile.js +5 -0
- package/dist/commands/queue.js +37 -0
- package/dist/commands/quit.js +6 -0
- package/dist/commands/rename.js +12 -0
- package/dist/commands/resolve-target.js +12 -0
- package/dist/commands/schedule.js +74 -0
- package/dist/commands/search.js +26 -0
- package/dist/commands/send.js +51 -0
- package/dist/commands/state.js +9 -0
- package/dist/commands/syntax.js +36 -0
- package/dist/commands/tasks.js +7 -0
- package/dist/commands/theme.js +41 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands.js +70 -0
- package/dist/completion/browser.js +24 -0
- package/dist/completion/fs.js +26 -0
- package/dist/completion/handlers.js +81 -0
- package/dist/completion/helpers.js +43 -0
- package/dist/completion/index.js +43 -0
- package/dist/completion-browser.js +24 -0
- package/dist/completion-fs.js +26 -0
- package/dist/completion-handlers.js +69 -0
- package/dist/completion-helpers.js +43 -0
- package/dist/completion.js +42 -0
- package/dist/config.js +66 -0
- package/dist/connection/close.js +41 -0
- package/dist/connection/manager.js +93 -0
- package/dist/connection/parsing.js +28 -0
- package/dist/connection-close.js +41 -0
- package/dist/connection-manager.js +93 -0
- package/dist/connection-parsing.js +28 -0
- package/dist/connections.js +75 -0
- package/dist/controller-events.js +34 -0
- package/dist/controller-file-tree.js +36 -0
- package/dist/controller.js +201 -0
- package/dist/database/index.js +83 -0
- package/dist/database/manager.js +67 -0
- package/dist/database/parsing.js +54 -0
- package/dist/database/query.js +41 -0
- package/dist/database-manager.js +67 -0
- package/dist/database-parsing.js +54 -0
- package/dist/database-query.js +41 -0
- package/dist/database.js +83 -0
- package/dist/datetime.js +15 -0
- package/dist/editor/save.js +27 -0
- package/dist/editor/sync.js +10 -0
- package/dist/editor/watch-manager.js +84 -0
- package/dist/editor-save.js +24 -0
- package/dist/editor-watch-manager.js +84 -0
- package/dist/file-tree-args.js +36 -0
- package/dist/file-tree-git-refresh.js +33 -0
- package/dist/file-tree-manager.js +252 -0
- package/dist/file-tree-moves.js +26 -0
- package/dist/file-tree-open.js +56 -0
- package/dist/file-tree-rebuild.js +22 -0
- package/dist/file-tree-watch.js +27 -0
- package/dist/file-tree.js +82 -0
- package/dist/git-status.js +64 -0
- package/dist/github-token.js +19 -0
- package/dist/global-history.js +48 -0
- package/dist/harness/auto-approve.js +79 -0
- package/dist/harness/busy-status.js +91 -0
- package/dist/harness/capture-file.js +28 -0
- package/dist/harness/index.js +87 -0
- package/dist/harness/manager.js +182 -0
- package/dist/harness/models.js +8 -0
- package/dist/harness/recorder.js +89 -0
- package/dist/harness/recording-file.js +25 -0
- package/dist/harness/screen.js +79 -0
- package/dist/harness-capture-file.js +28 -0
- package/dist/harness-manager.js +112 -0
- package/dist/harness-models.js +8 -0
- package/dist/harness-screen.js +72 -0
- package/dist/harness.js +54 -0
- package/dist/index.js +163 -0
- package/dist/instance-lock.js +34 -0
- package/dist/interactive.js +33 -0
- package/dist/main.js +203 -0
- package/dist/managers.js +1 -0
- package/dist/message-handler.js +134 -0
- package/dist/messaging.js +47 -0
- package/dist/monitor/acp.js +24 -0
- package/dist/monitor/ask.js +31 -0
- package/dist/monitor/context.js +27 -0
- package/dist/monitor/editor-feed.js +49 -0
- package/dist/monitor/feed-diff.js +22 -0
- package/dist/monitor/feeds.js +19 -0
- package/dist/monitor/framing.js +29 -0
- package/dist/monitor/harness-feed.js +21 -0
- package/dist/monitor/info.js +29 -0
- package/dist/monitor/manager.js +221 -0
- package/dist/monitor/page-feed.js +21 -0
- package/dist/monitor/parsing.js +82 -0
- package/dist/monitor/reply.js +11 -0
- package/dist/monitor/session.js +36 -0
- package/dist/monitor/stop.js +33 -0
- package/dist/monitor/suggestion.js +12 -0
- package/dist/monitor/targets.js +69 -0
- package/dist/monitor/window.js +109 -0
- package/dist/monitor-acp.js +22 -0
- package/dist/monitor-info.js +18 -0
- package/dist/monitor-manager.js +233 -0
- package/dist/monitor-parsing.js +68 -0
- package/dist/monitor-session.js +19 -0
- package/dist/monitor-targets.js +37 -0
- package/dist/monitor-window.js +81 -0
- package/dist/notifications-tab.js +32 -0
- package/dist/notifications.js +87 -0
- package/dist/open-file-manager.js +100 -0
- package/dist/openers/editor.js +41 -0
- package/dist/openers/external-viewer.js +11 -0
- package/dist/openers/image.js +27 -0
- package/dist/openers/index.js +16 -0
- package/dist/openers/markdown.js +20 -0
- package/dist/openers/os-open.js +24 -0
- package/dist/openers/page.js +51 -0
- package/dist/openers/size.js +13 -0
- package/dist/openers/types.js +1 -0
- package/dist/page/sync.js +10 -0
- package/dist/paths.js +45 -0
- package/dist/persona-parsing.js +49 -0
- package/dist/personas.js +44 -0
- package/dist/profile/agent-opener.js +154 -0
- package/dist/profile/files.js +17 -0
- package/dist/profile/manager.js +72 -0
- package/dist/profile/monitors.js +23 -0
- package/dist/profile/notifications.js +11 -0
- package/dist/profile-agent-opener.js +136 -0
- package/dist/profile-manager.js +72 -0
- package/dist/profiles.js +143 -0
- package/dist/protocol.js +1 -0
- package/dist/pseudoterminal-manager.js +106 -0
- package/dist/pty.js +41 -0
- package/dist/recognizers/acp.js +38 -0
- package/dist/recognizers/analyze.js +49 -0
- package/dist/recognizers/bash.js +51 -0
- package/dist/recognizers/db.js +26 -0
- package/dist/recognizers/index.js +1 -0
- package/dist/recognizers/lexicon.js +11 -0
- package/dist/recognizers/types.js +5 -0
- package/dist/resolve.js +39 -0
- package/dist/route-choice.js +11 -0
- package/dist/sandbox/index.js +217 -0
- package/dist/sandbox/paths.js +178 -0
- package/dist/sandbox/profile.js +161 -0
- package/dist/sandbox-paths.js +159 -0
- package/dist/sandbox-profile.js +161 -0
- package/dist/sandbox.js +217 -0
- package/dist/schedule/display.js +19 -0
- package/dist/schedule/helpers.js +76 -0
- package/dist/schedule/index.js +76 -0
- package/dist/schedule/manager.js +101 -0
- package/dist/schedule/parsing.js +63 -0
- package/dist/schedule/time.js +34 -0
- package/dist/schedule-display.js +19 -0
- package/dist/schedule-helpers.js +76 -0
- package/dist/schedule-manager.js +98 -0
- package/dist/schedule-parsing.js +63 -0
- package/dist/schedule-time.js +34 -0
- package/dist/schedule.js +76 -0
- package/dist/search-matches.js +40 -0
- package/dist/security.js +44 -0
- package/dist/shell-manager.js +139 -0
- package/dist/shell.js +59 -0
- package/dist/ssh-manager.js +42 -0
- package/dist/ssh.js +39 -0
- package/dist/startup-errors.js +21 -0
- package/dist/state-event.js +14 -0
- package/dist/state-format.js +38 -0
- package/dist/syntax-themes.js +14 -0
- package/dist/tab/agent-state.js +19 -0
- package/dist/tab/cleanup.js +33 -0
- package/dist/tab/colors.js +43 -0
- package/dist/tab/creators.js +95 -0
- package/dist/tab/dock.js +35 -0
- package/dist/tab/focus-history.js +41 -0
- package/dist/tab/formatting-handlers.js +77 -0
- package/dist/tab/formatting.js +21 -0
- package/dist/tab/history.js +14 -0
- package/dist/tab/index.js +65 -0
- package/dist/tab/manager.js +329 -0
- package/dist/tab/openers.js +35 -0
- package/dist/tab/queue.js +33 -0
- package/dist/tab/rehydrate.js +15 -0
- package/dist/tab/reorder.js +15 -0
- package/dist/tab/transcript.js +15 -0
- package/dist/tab/utils.js +55 -0
- package/dist/tab/view.js +42 -0
- package/dist/tab-cleanup.js +27 -0
- package/dist/tab-colors.js +43 -0
- package/dist/tab-creators.js +85 -0
- package/dist/tab-formatting-handlers.js +77 -0
- package/dist/tab-formatting.js +21 -0
- package/dist/tab-manager.js +418 -0
- package/dist/tab-utils.js +55 -0
- package/dist/tab.js +57 -0
- package/dist/tasks.js +32 -0
- package/dist/transcript/logger.js +36 -0
- package/dist/transcript/store.js +66 -0
- package/dist/types.js +6 -0
- package/dist/user-agent.js +65 -0
- package/dist/word-wrapping.js +42 -0
- package/dist/workspace-manager.js +40 -0
- package/dist/workspace.js +120 -0
- package/help.md +97 -0
- package/package.json +138 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { watch, statSync } from 'node:fs';
|
|
2
|
+
import { messageBus } from './bus.js';
|
|
3
|
+
const DEBOUNCE_MS = 100;
|
|
4
|
+
// Watches an open editor tab's underlying file for changes made by other processes, keyed by tab
|
|
5
|
+
// label. `markSaved` moves the baseline mtime forward before the write's own `fs.watch` event
|
|
6
|
+
// arrives, so the app's own saves never appear as an external change.
|
|
7
|
+
export class EditorWatchManager {
|
|
8
|
+
managers;
|
|
9
|
+
tabs = new Map();
|
|
10
|
+
constructor(managers) {
|
|
11
|
+
this.managers = managers;
|
|
12
|
+
}
|
|
13
|
+
watch(label, filePath) {
|
|
14
|
+
this.unwatch(label);
|
|
15
|
+
let baselineMtimeMs;
|
|
16
|
+
try {
|
|
17
|
+
baselineMtimeMs = statSync(filePath).mtimeMs;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const watcher = watch(filePath, () => this.scheduleCheck(label));
|
|
24
|
+
this.tabs.set(label, { filePath, watcher, baselineMtimeMs });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Exotic filesystems, fd limits, races — the editor still works, just without live reload.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Move the baseline forward after the app's own write, so the resulting `fs.watch` event isn't
|
|
31
|
+
// mistaken for an external change.
|
|
32
|
+
markSaved(label, mtimeMs) {
|
|
33
|
+
const state = this.tabs.get(label);
|
|
34
|
+
if (state)
|
|
35
|
+
state.baselineMtimeMs = mtimeMs;
|
|
36
|
+
}
|
|
37
|
+
closeTab(label) {
|
|
38
|
+
this.unwatch(label);
|
|
39
|
+
}
|
|
40
|
+
dispose() {
|
|
41
|
+
for (const label of this.tabs.keys())
|
|
42
|
+
this.unwatch(label);
|
|
43
|
+
}
|
|
44
|
+
unwatch(label) {
|
|
45
|
+
const state = this.tabs.get(label);
|
|
46
|
+
if (!state)
|
|
47
|
+
return;
|
|
48
|
+
if (state.debounce)
|
|
49
|
+
clearTimeout(state.debounce);
|
|
50
|
+
try {
|
|
51
|
+
state.watcher.close();
|
|
52
|
+
}
|
|
53
|
+
catch { /* already gone */ }
|
|
54
|
+
this.tabs.delete(label);
|
|
55
|
+
}
|
|
56
|
+
scheduleCheck(label) {
|
|
57
|
+
const state = this.tabs.get(label);
|
|
58
|
+
if (!state)
|
|
59
|
+
return;
|
|
60
|
+
if (state.debounce)
|
|
61
|
+
clearTimeout(state.debounce);
|
|
62
|
+
state.debounce = setTimeout(() => this.check(label), DEBOUNCE_MS);
|
|
63
|
+
}
|
|
64
|
+
check(label) {
|
|
65
|
+
const state = this.tabs.get(label);
|
|
66
|
+
if (!state)
|
|
67
|
+
return;
|
|
68
|
+
let mtimeMs;
|
|
69
|
+
try {
|
|
70
|
+
mtimeMs = statSync(state.filePath).mtimeMs;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (mtimeMs === state.baselineMtimeMs)
|
|
76
|
+
return;
|
|
77
|
+
state.baselineMtimeMs = mtimeMs;
|
|
78
|
+
const tab = this.managers.tab.tabs.find((t) => t.label === label);
|
|
79
|
+
if (!tab?.editor)
|
|
80
|
+
return;
|
|
81
|
+
tab.editor = { ...tab.editor, mtimeMs };
|
|
82
|
+
messageBus.emit('state', { type: 'dirty' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Parses the argument tail of a `files [left|right] [path]` / `files in <label> [on <side>]`
|
|
2
|
+
// command. Consumes leading `in <label>` / `on <left|right>` clauses (either order, each at most
|
|
3
|
+
// once), then falls back to the bare `left`/`right` keyword if neither clause was used. Whatever's
|
|
4
|
+
// left over is the path target.
|
|
5
|
+
export function parseFileTreeArgs(rest) {
|
|
6
|
+
let cursor = rest;
|
|
7
|
+
let inLabel;
|
|
8
|
+
let dock = null;
|
|
9
|
+
for (;;) {
|
|
10
|
+
if (inLabel === undefined) {
|
|
11
|
+
const inMatch = /^in\s+(\S+)\b\s*/i.exec(cursor);
|
|
12
|
+
if (inMatch) {
|
|
13
|
+
inLabel = inMatch[1];
|
|
14
|
+
cursor = cursor.slice(inMatch[0].length);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (dock === null) {
|
|
19
|
+
const onMatch = /^on\s+(left|right)\b\s*/i.exec(cursor);
|
|
20
|
+
if (onMatch) {
|
|
21
|
+
dock = onMatch[1].toLowerCase();
|
|
22
|
+
cursor = cursor.slice(onMatch[0].length);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (inLabel === undefined && dock === null) {
|
|
29
|
+
const keyword = /^(left|right)\b\s*/i.exec(cursor);
|
|
30
|
+
if (keyword) {
|
|
31
|
+
dock = keyword[1].toLowerCase();
|
|
32
|
+
cursor = cursor.slice(keyword[0].length);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { inLabel, dock, target: cursor.trim() };
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { changedPaths, currentBranch } from './git-status.js';
|
|
2
|
+
// Recompute one tab's git-changed set and current branch off the event loop, then re-render with
|
|
3
|
+
// them via `rebuild` (decision 6's second `dirty` emit). Coalesced: if a refresh is already in
|
|
4
|
+
// flight for this tab, the request only sets a stale bit and exactly one follow-up runs when the
|
|
5
|
+
// current one resolves — no overlapping git processes. The captured `root` guards against a
|
|
6
|
+
// mid-flight `reroot` or tab close: a result whose tab is gone or whose root has changed is
|
|
7
|
+
// discarded, never written.
|
|
8
|
+
export function refreshGit(states, label, rebuild) {
|
|
9
|
+
const state = states.get(label);
|
|
10
|
+
if (!state)
|
|
11
|
+
return;
|
|
12
|
+
if (state.gitRefreshing) {
|
|
13
|
+
state.gitRefreshStale = true;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
state.gitRefreshing = true;
|
|
17
|
+
const root = state.root;
|
|
18
|
+
void Promise.all([changedPaths(root), currentBranch(root)]).then(([changed, branch]) => {
|
|
19
|
+
const current = states.get(label);
|
|
20
|
+
if (!current)
|
|
21
|
+
return;
|
|
22
|
+
if (current.root === root) {
|
|
23
|
+
current.changed = changed;
|
|
24
|
+
current.branch = branch;
|
|
25
|
+
rebuild(label);
|
|
26
|
+
}
|
|
27
|
+
current.gitRefreshing = false;
|
|
28
|
+
if (current.gitRefreshStale) {
|
|
29
|
+
current.gitRefreshStale = false;
|
|
30
|
+
refreshGit(states, label, rebuild);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { statSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { messageBus } from './bus.js';
|
|
4
|
+
import { buildRows, isSameOrDescendantPath, parentPath } from './file-tree.js';
|
|
5
|
+
import { refreshGit } from './file-tree-git-refresh.js';
|
|
6
|
+
import { parseFileTreeArgs } from './file-tree-args.js';
|
|
7
|
+
import { expandUserPath } from './paths.js';
|
|
8
|
+
import { resolveTarget } from './commands/resolve-target.js';
|
|
9
|
+
import { openOrRetarget } from './file-tree-open.js';
|
|
10
|
+
import { applyStackMove } from './file-tree-moves.js';
|
|
11
|
+
import { watchDir, unwatchDir } from './file-tree-watch.js';
|
|
12
|
+
import { pruneAndBuildRows } from './file-tree-rebuild.js';
|
|
13
|
+
const DEBOUNCE_MS = 100;
|
|
14
|
+
// Owns file tree tabs: opening/focusing them, their `expanded` directory sets, and one
|
|
15
|
+
// non-recursive `fs.watch` per visible directory. Any watch event schedules a single per-tab
|
|
16
|
+
// debounced rebuild; the server always owns the tree — the client only ever renders rows.
|
|
17
|
+
export class FileTreeManager {
|
|
18
|
+
managers;
|
|
19
|
+
tabs = new Map();
|
|
20
|
+
constructor(managers) {
|
|
21
|
+
this.managers = managers;
|
|
22
|
+
}
|
|
23
|
+
// Handle a `files [left|right] [path]` command: open a new tree tab rooted at `path` (or the
|
|
24
|
+
// issuing tab's cwd), or focus/redock the existing tab if one is already open on that root.
|
|
25
|
+
// A leading `left`/`right` keyword docks the tab into that sidebar; a directory literally named
|
|
26
|
+
// `left`/`right` is still reachable via a path form (`files ./left`), since the keyword is only
|
|
27
|
+
// recognized as the first word. `in <label>` roots the tree at another tab's cwd instead of the
|
|
28
|
+
// issuing tab's, and `on <left|right>` is an explicit spelling of the same docking the bare
|
|
29
|
+
// keyword provides; both are optional, independent, and may appear in either order (`files in
|
|
30
|
+
// claude on left`). Like `left`/`right`, they are only recognized as clause keywords — a
|
|
31
|
+
// directory literally named `in`/`on` stays reachable via a path form (`files ./in`).
|
|
32
|
+
open(command, label) {
|
|
33
|
+
const rest = command.replace(/^files\b\s*/i, '');
|
|
34
|
+
const { inLabel, dock, target } = parseFileTreeArgs(rest);
|
|
35
|
+
const out = (text) => this.managers.tab.append(label, { input: command, output: text });
|
|
36
|
+
let cwd;
|
|
37
|
+
if (inLabel === undefined) {
|
|
38
|
+
cwd = this.managers.tab.cwdOf(label) ?? process.cwd();
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const sourceTab = resolveTarget(inLabel, this.managers, out);
|
|
42
|
+
if (!sourceTab)
|
|
43
|
+
return;
|
|
44
|
+
cwd = this.managers.tab.cwdOf(sourceTab.label) ?? process.cwd();
|
|
45
|
+
}
|
|
46
|
+
const expandedPath = target ? expandUserPath(target, { root: this.managers.tab.launchDir }) : '';
|
|
47
|
+
const root = target ? (path.isAbsolute(expandedPath) ? expandedPath : path.resolve(cwd, expandedPath)) : cwd;
|
|
48
|
+
let stat;
|
|
49
|
+
try {
|
|
50
|
+
stat = statSync(root);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
stat = undefined;
|
|
54
|
+
}
|
|
55
|
+
if (!stat?.isDirectory()) {
|
|
56
|
+
out(`files: ${root}: not a directory`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const existing = this.managers.tab.tabs.find((t) => t.files?.root === root);
|
|
60
|
+
if (existing) {
|
|
61
|
+
this.managers.tab.setDock(this.managers.tab.findIndex(existing.label), dock);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const expanded = new Set();
|
|
65
|
+
this.managers.tab.openFilesTab({ root, absoluteRoot: root, rows: buildRows(root, expanded) });
|
|
66
|
+
const newLabel = this.managers.tab.cur().label;
|
|
67
|
+
this.managers.tab.setCwd(newLabel, root);
|
|
68
|
+
this.tabs.set(newLabel, { root, expanded, watchers: new Map(), undoStack: [], redoStack: [], changed: new Set() });
|
|
69
|
+
this.watchDir(newLabel, root, '');
|
|
70
|
+
if (dock)
|
|
71
|
+
this.managers.tab.setDock(this.managers.tab.findIndex(newLabel), dock);
|
|
72
|
+
this.refreshGit(newLabel);
|
|
73
|
+
}
|
|
74
|
+
// Expand/collapse one directory row.
|
|
75
|
+
toggle(label, relPath) {
|
|
76
|
+
const state = this.tabs.get(label);
|
|
77
|
+
if (!state)
|
|
78
|
+
return;
|
|
79
|
+
if (state.expanded.has(relPath)) {
|
|
80
|
+
state.expanded.delete(relPath);
|
|
81
|
+
this.unwatchDir(state, relPath);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
state.expanded.add(relPath);
|
|
85
|
+
this.watchDir(label, path.join(state.root, relPath), relPath);
|
|
86
|
+
}
|
|
87
|
+
this.rebuild(label);
|
|
88
|
+
}
|
|
89
|
+
// Collapse every expanded directory back to just the root.
|
|
90
|
+
collapseAll(label) {
|
|
91
|
+
const state = this.tabs.get(label);
|
|
92
|
+
if (!state)
|
|
93
|
+
return;
|
|
94
|
+
for (const relPath of state.expanded)
|
|
95
|
+
this.unwatchDir(state, relPath);
|
|
96
|
+
state.expanded.clear();
|
|
97
|
+
this.rebuild(label);
|
|
98
|
+
}
|
|
99
|
+
// Re-root the tree to the parent directory. Clears expanded state and watchers, then rebuilds.
|
|
100
|
+
reroot(label, relPath) {
|
|
101
|
+
const state = this.tabs.get(label);
|
|
102
|
+
if (!state)
|
|
103
|
+
return;
|
|
104
|
+
const target = relPath ? path.resolve(state.root, relPath) : path.resolve(state.root, '..');
|
|
105
|
+
if (target === state.root)
|
|
106
|
+
return;
|
|
107
|
+
for (const relPath of state.expanded)
|
|
108
|
+
this.unwatchDir(state, relPath);
|
|
109
|
+
state.expanded.clear();
|
|
110
|
+
this.unwatchDir(state, '');
|
|
111
|
+
state.root = target;
|
|
112
|
+
state.changed = new Set();
|
|
113
|
+
state.branch = undefined;
|
|
114
|
+
this.watchDir(label, target, '');
|
|
115
|
+
if (this.managers.tab.tabs.some((t) => t.label === label))
|
|
116
|
+
this.managers.tab.setCwd(label, target);
|
|
117
|
+
this.rebuild(label);
|
|
118
|
+
this.refreshGit(label);
|
|
119
|
+
}
|
|
120
|
+
// Open a file navigator at `label`'s cwd (the metadata-row 📁 button). If a file-tree tab is
|
|
121
|
+
// already open, retarget the most-recently-focused one to that cwd in place — preserving its
|
|
122
|
+
// identity, dock placement, and strip position; otherwise open a fresh tree docked in the left
|
|
123
|
+
// sidebar. Either way, focus stays on the tab whose button was clicked. See `file-tree-open.ts`.
|
|
124
|
+
openOrRetarget(label) {
|
|
125
|
+
openOrRetarget(this.openPort(), label);
|
|
126
|
+
}
|
|
127
|
+
// The narrow set of manager internals `file-tree-open.ts` operates through, passed as bound
|
|
128
|
+
// closures so the tab-state map and watcher methods stay private to this class.
|
|
129
|
+
openPort() {
|
|
130
|
+
return {
|
|
131
|
+
managers: this.managers,
|
|
132
|
+
states: this.tabs,
|
|
133
|
+
watchDir: (label, absDir, relPath) => this.watchDir(label, absDir, relPath),
|
|
134
|
+
unwatchDir: (state, relPath) => this.unwatchDir(state, relPath),
|
|
135
|
+
rebuild: (label) => this.rebuild(label),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Move a file or directory into a different directory (drag-and-release in the tree). Rejects
|
|
139
|
+
// moving an item onto itself or into one of its own descendants; a same-named entry already at
|
|
140
|
+
// the destination is overwritten (the client has already confirmed that via its own dialog
|
|
141
|
+
// before sending this). Pushes the move onto the tab's undo stack and clears its redo stack —
|
|
142
|
+
// mirroring the editor's own "any new edit invalidates the redo stack" rule. Rebuilds so the
|
|
143
|
+
// tree reflects the change immediately, without waiting on the directory watcher's own debounce.
|
|
144
|
+
move(label, fromRelPath, toRelPath) {
|
|
145
|
+
const state = this.tabs.get(label);
|
|
146
|
+
if (!state)
|
|
147
|
+
return;
|
|
148
|
+
if (isSameOrDescendantPath(toRelPath, fromRelPath))
|
|
149
|
+
return;
|
|
150
|
+
const fromAbs = path.join(state.root, fromRelPath);
|
|
151
|
+
const name = path.basename(fromAbs);
|
|
152
|
+
const toAbs = path.join(state.root, toRelPath, name);
|
|
153
|
+
try {
|
|
154
|
+
renameSync(fromAbs, toAbs);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
state.undoStack.push({ from: fromRelPath, to: toRelPath ? `${toRelPath}/${name}` : name });
|
|
160
|
+
state.redoStack = [];
|
|
161
|
+
this.rebuild(label);
|
|
162
|
+
}
|
|
163
|
+
// Undo the most recent move: moves the item back from `to` to `from`'s original directory. A
|
|
164
|
+
// conflict at the destination is reported back without mutating either stack, so a caller-driven
|
|
165
|
+
// overwrite (passing `overwrite: true`) can retry the same pending entry. An empty undo stack is
|
|
166
|
+
// a silent no-op.
|
|
167
|
+
undo(label, overwrite = false) {
|
|
168
|
+
const state = this.tabs.get(label);
|
|
169
|
+
if (!state)
|
|
170
|
+
return {};
|
|
171
|
+
const entry = state.undoStack.at(-1);
|
|
172
|
+
if (!entry)
|
|
173
|
+
return {};
|
|
174
|
+
return applyStackMove(state.root, entry.to, parentPath(entry.from), entry, state.undoStack, state.redoStack, overwrite, () => this.rebuild(label));
|
|
175
|
+
}
|
|
176
|
+
// Redo the most recently undone move: re-applies it from `from` to `to`'s original directory.
|
|
177
|
+
// Same conflict-reporting and no-op behavior as `undo`.
|
|
178
|
+
redo(label, overwrite = false) {
|
|
179
|
+
const state = this.tabs.get(label);
|
|
180
|
+
if (!state)
|
|
181
|
+
return {};
|
|
182
|
+
const entry = state.redoStack.at(-1);
|
|
183
|
+
if (!entry)
|
|
184
|
+
return {};
|
|
185
|
+
return applyStackMove(state.root, entry.from, parentPath(entry.to), entry, state.redoStack, state.undoStack, overwrite, () => this.rebuild(label));
|
|
186
|
+
}
|
|
187
|
+
// Delete a file or directory (recursively) from disk — the client has already confirmed with
|
|
188
|
+
// the user before sending this. Rebuilds so the tree reflects the removal immediately, without
|
|
189
|
+
// waiting on the directory watcher's own debounce.
|
|
190
|
+
delete(label, relPath) {
|
|
191
|
+
const state = this.tabs.get(label);
|
|
192
|
+
if (!state)
|
|
193
|
+
return;
|
|
194
|
+
const abs = path.join(state.root, relPath);
|
|
195
|
+
try {
|
|
196
|
+
rmSync(abs, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.rebuild(label);
|
|
202
|
+
}
|
|
203
|
+
// Tear down one tab's watchers and debounce timer (on tab close).
|
|
204
|
+
closeTab(label) {
|
|
205
|
+
const state = this.tabs.get(label);
|
|
206
|
+
if (!state)
|
|
207
|
+
return;
|
|
208
|
+
if (state.debounce)
|
|
209
|
+
clearTimeout(state.debounce);
|
|
210
|
+
for (const watcher of state.watchers.values()) {
|
|
211
|
+
try {
|
|
212
|
+
watcher.close();
|
|
213
|
+
}
|
|
214
|
+
catch { /* already gone */ }
|
|
215
|
+
}
|
|
216
|
+
this.tabs.delete(label);
|
|
217
|
+
}
|
|
218
|
+
// Tear down every tab's watchers (app shutdown).
|
|
219
|
+
dispose() {
|
|
220
|
+
for (const label of this.tabs.keys())
|
|
221
|
+
this.closeTab(label);
|
|
222
|
+
}
|
|
223
|
+
watchDir(label, absDir, relPath) {
|
|
224
|
+
watchDir(this.tabs, label, absDir, relPath, () => this.scheduleRebuild(label));
|
|
225
|
+
}
|
|
226
|
+
unwatchDir(state, relPath) {
|
|
227
|
+
unwatchDir(state, relPath);
|
|
228
|
+
}
|
|
229
|
+
scheduleRebuild(label) {
|
|
230
|
+
const state = this.tabs.get(label);
|
|
231
|
+
if (!state)
|
|
232
|
+
return;
|
|
233
|
+
if (state.debounce)
|
|
234
|
+
clearTimeout(state.debounce);
|
|
235
|
+
state.debounce = setTimeout(() => { this.rebuild(label); this.refreshGit(label); }, DEBOUNCE_MS);
|
|
236
|
+
}
|
|
237
|
+
refreshGit(label) {
|
|
238
|
+
refreshGit(this.tabs, label, (l) => this.rebuild(l));
|
|
239
|
+
}
|
|
240
|
+
// Rebuild the visible row list (pruning expanded directories that no longer exist) and write it
|
|
241
|
+
// onto the tab's payload.
|
|
242
|
+
rebuild(label) {
|
|
243
|
+
const state = this.tabs.get(label);
|
|
244
|
+
if (!state)
|
|
245
|
+
return;
|
|
246
|
+
const tab = this.managers.tab.tabs.find((t) => t.label === label);
|
|
247
|
+
if (!tab?.files)
|
|
248
|
+
return;
|
|
249
|
+
tab.files = { root: state.root, absoluteRoot: state.root, rows: pruneAndBuildRows(state), branch: state.branch };
|
|
250
|
+
messageBus.emit('state', { type: 'dirty' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { renameSync } from 'node:fs';
|
|
3
|
+
import { hasNameConflict } from './file-tree.js';
|
|
4
|
+
// Shared reverse/re-apply logic for `undo`/`redo`: moves `sourceRel` (relative to `root`) into
|
|
5
|
+
// `destDir`, unless the destination already has a same-named entry and the caller hasn't confirmed
|
|
6
|
+
// an overwrite, in which case it reports the conflict and leaves both stacks untouched. On success
|
|
7
|
+
// it pops `fromStack`, pushes `entry` onto `toStack`, and invokes `rebuild`.
|
|
8
|
+
export function applyStackMove(root, sourceRel, destDir, entry, fromStack, toStack, overwrite, rebuild) {
|
|
9
|
+
const sourceAbs = path.join(root, sourceRel);
|
|
10
|
+
const name = path.basename(sourceAbs);
|
|
11
|
+
const destAbsDir = path.join(root, destDir);
|
|
12
|
+
const destAbs = path.join(destAbsDir, name);
|
|
13
|
+
if (!overwrite && sourceAbs !== destAbs && hasNameConflict(destAbsDir, name)) {
|
|
14
|
+
return { conflict: { fromRelPath: sourceRel, toRelPath: destDir } };
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
renameSync(sourceAbs, destAbs);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
fromStack.pop();
|
|
23
|
+
toStack.push(entry);
|
|
24
|
+
rebuild();
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { buildRows } from './file-tree.js';
|
|
3
|
+
// Open a file navigator at `label`'s cwd (the metadata-row 📁 button). If a file-tree tab is already
|
|
4
|
+
// open, retarget the most-recently-focused one to that cwd in place — preserving its identity, dock
|
|
5
|
+
// placement, and strip position; otherwise open a fresh tree docked in the left sidebar. Either way,
|
|
6
|
+
// focus stays on the tab whose button was clicked — opening or retargeting the navigator must not
|
|
7
|
+
// steal focus.
|
|
8
|
+
export function openOrRetarget(port, label) {
|
|
9
|
+
const cwd = port.managers.tab.cwdOf(label) ?? process.cwd();
|
|
10
|
+
let stat;
|
|
11
|
+
try {
|
|
12
|
+
stat = statSync(cwd);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
stat = undefined;
|
|
16
|
+
}
|
|
17
|
+
if (!stat?.isDirectory())
|
|
18
|
+
return;
|
|
19
|
+
const existing = port.managers.tab.mostRecentFileTreeLabel();
|
|
20
|
+
if (existing)
|
|
21
|
+
retarget(port, existing, cwd);
|
|
22
|
+
else
|
|
23
|
+
openFresh(port, cwd);
|
|
24
|
+
port.managers.tab.setActiveTab(port.managers.tab.findIndex(label));
|
|
25
|
+
}
|
|
26
|
+
// Open a fresh tree rooted at `root`, docked left by default (unlike the bare `files` command's
|
|
27
|
+
// center-strip default). Mirrors `FileTreeManager.open()`'s create-and-watch sequence.
|
|
28
|
+
function openFresh(port, root) {
|
|
29
|
+
const expanded = new Set();
|
|
30
|
+
port.managers.tab.openFilesTab({ root, absoluteRoot: root, rows: buildRows(root, expanded) });
|
|
31
|
+
const newLabel = port.managers.tab.cur().label;
|
|
32
|
+
port.managers.tab.setCwd(newLabel, root);
|
|
33
|
+
port.states.set(newLabel, { root, expanded, watchers: new Map(), undoStack: [], redoStack: [] });
|
|
34
|
+
port.watchDir(newLabel, root, '');
|
|
35
|
+
port.managers.tab.setDock(port.managers.tab.findIndex(newLabel), 'left');
|
|
36
|
+
}
|
|
37
|
+
// Retarget an existing file-tree tab's root to an arbitrary absolute directory in place. Reuses
|
|
38
|
+
// `reroot()`'s clear-expanded/unwatch/rewatch/rebuild sequence, but — unlike `reroot()` — also
|
|
39
|
+
// clears the undo/redo stacks, since jumping to an unrelated directory leaves their relative paths
|
|
40
|
+
// meaningless at the new root.
|
|
41
|
+
function retarget(port, label, root) {
|
|
42
|
+
const state = port.states.get(label);
|
|
43
|
+
if (!state || root === state.root)
|
|
44
|
+
return;
|
|
45
|
+
for (const relPath of state.expanded)
|
|
46
|
+
port.unwatchDir(state, relPath);
|
|
47
|
+
state.expanded.clear();
|
|
48
|
+
port.unwatchDir(state, '');
|
|
49
|
+
state.root = root;
|
|
50
|
+
state.undoStack = [];
|
|
51
|
+
state.redoStack = [];
|
|
52
|
+
port.watchDir(label, root, '');
|
|
53
|
+
if (port.managers.tab.tabs.some((t) => t.label === label))
|
|
54
|
+
port.managers.tab.setCwd(label, root);
|
|
55
|
+
port.rebuild(label);
|
|
56
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildRows, markChanged } from './file-tree.js';
|
|
4
|
+
import { unwatchDir } from './file-tree-watch.js';
|
|
5
|
+
// Prunes expanded directories that no longer exist on disk (closing their watchers) and returns
|
|
6
|
+
// the current visible row list for `state.root`, with git-changed paths marked.
|
|
7
|
+
export function pruneAndBuildRows(state) {
|
|
8
|
+
for (const relPath of state.expanded) {
|
|
9
|
+
let stillDir;
|
|
10
|
+
try {
|
|
11
|
+
stillDir = statSync(path.join(state.root, relPath)).isDirectory();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
stillDir = false;
|
|
15
|
+
}
|
|
16
|
+
if (!stillDir) {
|
|
17
|
+
state.expanded.delete(relPath);
|
|
18
|
+
unwatchDir(state, relPath);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return markChanged(buildRows(state.root, state.expanded), state.changed ?? new Set());
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
// Starts a non-recursive `fs.watch` on `absDir` (keyed by `relPath` in `label`'s watcher map),
|
|
3
|
+
// invoking `onChange` on every event. A no-op if the tab is unknown or already watching that path.
|
|
4
|
+
// Exotic filesystems, fd limits, and races are swallowed — the tree still works, just refreshes on
|
|
5
|
+
// toggle instead of live.
|
|
6
|
+
export function watchDir(states, label, absDir, relPath, onChange) {
|
|
7
|
+
const state = states.get(label);
|
|
8
|
+
if (!state || state.watchers.has(relPath))
|
|
9
|
+
return;
|
|
10
|
+
try {
|
|
11
|
+
state.watchers.set(relPath, watch(absDir, onChange));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Exotic filesystems, fd limits, races — the tree still works, just refreshes on toggle.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Stops and forgets the watcher at `relPath`, if any.
|
|
18
|
+
export function unwatchDir(state, relPath) {
|
|
19
|
+
const watcher = state.watchers.get(relPath);
|
|
20
|
+
if (!watcher)
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
watcher.close();
|
|
24
|
+
}
|
|
25
|
+
catch { /* already gone */ }
|
|
26
|
+
state.watchers.delete(relPath);
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// VS Code's `files.exclude` defaults. Other dotfiles are shown.
|
|
4
|
+
const EXCLUDES = new Set(['.git', '.svn', '.hg', '.DS_Store', 'Thumbs.db']);
|
|
5
|
+
// One directory's sorted, filtered entries: directories first, then files, `localeCompare`
|
|
6
|
+
// case-insensitive within each group. A symlink (file or directory) reports as a file — never
|
|
7
|
+
// expandable — which is the cheap way to stay cycle-proof. An unreadable directory (permission
|
|
8
|
+
// denied, deleted mid-read) yields [].
|
|
9
|
+
export function readDirSorted(absDir) {
|
|
10
|
+
let dirents;
|
|
11
|
+
try {
|
|
12
|
+
dirents = readdirSync(absDir, { withFileTypes: true });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const entries = dirents
|
|
18
|
+
.filter((d) => !EXCLUDES.has(d.name))
|
|
19
|
+
.map((d) => ({ name: d.name, dir: d.isDirectory() }));
|
|
20
|
+
return entries.toSorted((a, b) => {
|
|
21
|
+
if (a.dir !== b.dir)
|
|
22
|
+
return a.dir ? -1 : 1;
|
|
23
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// A depth-first, pre-flattened list of the currently *visible* rows: the root's direct children,
|
|
27
|
+
// plus — for each directory whose path is in `expanded` — that directory's children too, and so
|
|
28
|
+
// on. Children are read from disk only for expanded directories. An `expanded` entry for a path
|
|
29
|
+
// that no longer exists (or isn't found under its parent) is simply never reached, so it's
|
|
30
|
+
// naturally skipped rather than needing special-case pruning here.
|
|
31
|
+
export function buildRows(root, expanded) {
|
|
32
|
+
const rows = [];
|
|
33
|
+
const walk = (absDir, relDir, depth) => {
|
|
34
|
+
for (const entry of readDirSorted(absDir)) {
|
|
35
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
36
|
+
const isExpanded = entry.dir && expanded.has(relPath);
|
|
37
|
+
rows.push({
|
|
38
|
+
path: relPath, name: entry.name, depth, dir: entry.dir,
|
|
39
|
+
...(entry.dir && { expanded: isExpanded }),
|
|
40
|
+
});
|
|
41
|
+
if (isExpanded)
|
|
42
|
+
walk(path.join(absDir, entry.name), relPath, depth + 1);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(root, '', 0);
|
|
46
|
+
if (path.dirname(root) !== root) {
|
|
47
|
+
rows.unshift({ path: '..', name: '..', depth: 0, dir: true });
|
|
48
|
+
}
|
|
49
|
+
return rows;
|
|
50
|
+
}
|
|
51
|
+
// Return `rows` with a `changed` flag set on every row git considers changed: a file row when its
|
|
52
|
+
// own `path` is in `changed`; a directory row when any changed path is nested beneath it (a prefix
|
|
53
|
+
// check, `changedPath` starts with `${row.path}/`). Propagation is purely this flat-set prefix
|
|
54
|
+
// scan — no directory is re-read, so a collapsed directory still colors when something deep inside
|
|
55
|
+
// it changed. Rows with no match are returned as-is; an empty `changed` set marks nothing.
|
|
56
|
+
export function markChanged(rows, changed) {
|
|
57
|
+
if (changed.size === 0)
|
|
58
|
+
return rows;
|
|
59
|
+
return rows.map((row) => {
|
|
60
|
+
const isChanged = row.dir
|
|
61
|
+
? [...changed].some((p) => p.startsWith(`${row.path}/`))
|
|
62
|
+
: changed.has(row.path);
|
|
63
|
+
return isChanged ? { ...row, changed: true } : row;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// The containing directory of a tree-relative path — the empty string for a root-level entry,
|
|
67
|
+
// matching the root-as-empty-string convention `buildRows` already uses.
|
|
68
|
+
export function parentPath(relPath) {
|
|
69
|
+
const idx = relPath.lastIndexOf('/');
|
|
70
|
+
return idx === -1 ? '' : relPath.slice(0, idx);
|
|
71
|
+
}
|
|
72
|
+
// True if `candidate` is `base` itself, or is nested inside it — the check that blocks dropping a
|
|
73
|
+
// dragged item onto itself or one of its own descendants (moving a directory into its own child).
|
|
74
|
+
export function isSameOrDescendantPath(candidate, base) {
|
|
75
|
+
return candidate === base || candidate.startsWith(`${base}/`);
|
|
76
|
+
}
|
|
77
|
+
// True if the directory at `absDestDir` already has a child named `name` — used to detect a
|
|
78
|
+
// same-name conflict at the drop target. Re-reads the destination directory fresh from disk
|
|
79
|
+
// rather than trusting the client's row list, which can be briefly stale.
|
|
80
|
+
export function hasNameConflict(absDestDir, name) {
|
|
81
|
+
return readDirSorted(absDestDir).some((e) => e.name === name);
|
|
82
|
+
}
|