@narumitw/pi-files 0.0.0-stage → 0.1.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
- package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
- package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
- package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
- package/dist/chunks/chunk-F5QFC255.ts +101 -0
- package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
- package/dist/chunks/chunk-NFNGJETD.ts +77 -0
- package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
- package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
- package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
- package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
- package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
- package/dist/chunks/menu-P6IBMROH.ts +876 -0
- package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
- package/dist/index.ts +1789 -0
- package/dist/index.ts.map +7 -0
- package/docs/edit-scope-reference.md +142 -0
- package/docs/file-context-reference.md +91 -0
- package/docs/migration.md +105 -0
- package/docs/verification.md +99 -0
- package/package.json +58 -4
- package/src/content-search-session.ts +204 -0
- package/src/content-search-ui.ts +159 -0
- package/src/content-search.ts +170 -0
- package/src/context-service.ts +515 -0
- package/src/external-editor.ts +143 -0
- package/src/file-browser-ui.ts +128 -0
- package/src/file-browser.ts +76 -0
- package/src/file-context-explorer.ts +992 -0
- package/src/file-context-menu.ts +360 -0
- package/src/file-context-preview-ui.ts +354 -0
- package/src/file-context-settings.ts +301 -0
- package/src/file-context.ts +321 -0
- package/src/file-search.ts +246 -0
- package/src/files-menu.ts +125 -0
- package/src/files-settings.ts +314 -0
- package/src/files-widget.ts +65 -0
- package/src/files.ts +148 -0
- package/src/git-context.ts +485 -0
- package/src/index.ts +1 -0
- package/src/scope/contracts.ts +243 -0
- package/src/scope/display.ts +27 -0
- package/src/scope/edit-scope.ts +435 -0
- package/src/scope/files.ts +118 -0
- package/src/scope/input.ts +207 -0
- package/src/scope/menu.ts +277 -0
- package/src/scope/paths.ts +142 -0
- package/src/scope/picker-keys.ts +154 -0
- package/src/scope/picker.ts +484 -0
- package/src/scope/policy.ts +55 -0
- package/src/scope/settings.ts +138 -0
- package/src/scope/state.ts +91 -0
- package/src/scope/ui.ts +106 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getKeybindings, matchesKey, type TUI, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatInteractionHints } from "@narumitw/pi-tui-kit";
|
|
4
|
+
|
|
5
|
+
export type PickerPanel = "files" | "path";
|
|
6
|
+
export type PickerAction =
|
|
7
|
+
| "close"
|
|
8
|
+
| "cancel"
|
|
9
|
+
| "up"
|
|
10
|
+
| "down"
|
|
11
|
+
| "pageUp"
|
|
12
|
+
| "pageDown"
|
|
13
|
+
| "apply"
|
|
14
|
+
| "mark"
|
|
15
|
+
| "markUp"
|
|
16
|
+
| "paths"
|
|
17
|
+
| "add"
|
|
18
|
+
| "submit"
|
|
19
|
+
| "input";
|
|
20
|
+
const selection = [
|
|
21
|
+
["cancel", "tui.select.cancel"],
|
|
22
|
+
["up", "tui.select.up"],
|
|
23
|
+
["down", "tui.select.down"],
|
|
24
|
+
["pageUp", "tui.select.pageUp"],
|
|
25
|
+
["pageDown", "tui.select.pageDown"],
|
|
26
|
+
["apply", "tui.select.confirm"],
|
|
27
|
+
] as const;
|
|
28
|
+
const editing = [
|
|
29
|
+
"tui.editor.undo",
|
|
30
|
+
"tui.input.submit",
|
|
31
|
+
"tui.editor.deleteCharBackward",
|
|
32
|
+
"tui.editor.deleteCharForward",
|
|
33
|
+
"tui.editor.deleteWordBackward",
|
|
34
|
+
"tui.editor.deleteWordForward",
|
|
35
|
+
"tui.editor.deleteToLineStart",
|
|
36
|
+
"tui.editor.deleteToLineEnd",
|
|
37
|
+
"tui.editor.yank",
|
|
38
|
+
"tui.editor.yankPop",
|
|
39
|
+
"tui.editor.cursorLeft",
|
|
40
|
+
"tui.editor.cursorRight",
|
|
41
|
+
"tui.editor.cursorLineStart",
|
|
42
|
+
"tui.editor.cursorLineEnd",
|
|
43
|
+
"tui.editor.cursorWordLeft",
|
|
44
|
+
"tui.editor.cursorWordRight",
|
|
45
|
+
] as const;
|
|
46
|
+
const viewportBindings = Object.keys(TUI_KEYBINDINGS).filter(
|
|
47
|
+
(binding) => binding.startsWith("tui.altScreen.") && !binding.startsWith("tui.altScreen.search"),
|
|
48
|
+
) as (keyof typeof TUI_KEYBINDINGS)[];
|
|
49
|
+
// Prove familiar input families with Pi's matcher, without guessing arbitrary remaps.
|
|
50
|
+
// Unproven keys still dispatch natively, but do not receive a literal key hint.
|
|
51
|
+
const hintInputs = [
|
|
52
|
+
"\r",
|
|
53
|
+
"\n",
|
|
54
|
+
"\x1bOM",
|
|
55
|
+
"\x1b",
|
|
56
|
+
"\t",
|
|
57
|
+
"\x1b[Z",
|
|
58
|
+
"\x10",
|
|
59
|
+
"\x0f",
|
|
60
|
+
"\x1bo",
|
|
61
|
+
"\x1b[111;3u",
|
|
62
|
+
"\x1b[A",
|
|
63
|
+
"\x1b[B",
|
|
64
|
+
"\x1b[5~",
|
|
65
|
+
"\x1b[6~",
|
|
66
|
+
...["OP", "OQ", "OR", "OS", "[15~", "[17~", "[18~", "[19~", "[20~", "[21~", "[23~", "[24~"].map(
|
|
67
|
+
(suffix) => `\x1b${suffix}`,
|
|
68
|
+
),
|
|
69
|
+
..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export function pickerKeys(tui: TUI, keybindings: KeybindingsManager) {
|
|
73
|
+
const hostAllows = (data: string) => {
|
|
74
|
+
if (tui.onDebug && matchesKey(data, "shift+ctrl+d")) return false;
|
|
75
|
+
if (tui.mode !== "fullscreen") return true;
|
|
76
|
+
const host = getKeybindings();
|
|
77
|
+
if (host.matches(data, "tui.altScreen.search")) return false;
|
|
78
|
+
return tui.hasOverlay() || !viewportBindings.some((binding) => host.matches(data, binding));
|
|
79
|
+
};
|
|
80
|
+
const filesAction = (data: string): PickerAction | undefined => {
|
|
81
|
+
if (matchesKey(data, "ctrl+c")) return "close";
|
|
82
|
+
for (const [name, binding] of selection) if (keybindings.matches(data, binding)) return name;
|
|
83
|
+
// Native editing/submission wins over additional marking and view shortcuts.
|
|
84
|
+
if (data === "\n" || editing.some((binding) => keybindings.matches(data, binding))) return "input";
|
|
85
|
+
if (keybindings.matches(data, "tui.input.tab")) return "mark";
|
|
86
|
+
if (matchesKey(data, "shift+tab")) return "markUp";
|
|
87
|
+
if (keybindings.matches(data, "app.session.togglePath")) return "paths";
|
|
88
|
+
};
|
|
89
|
+
const addKey = () =>
|
|
90
|
+
(["ctrl+o", "alt+o"] as const).find((key) => {
|
|
91
|
+
const inputs = hintInputs.filter((input) => matchesKey(input, key));
|
|
92
|
+
return inputs.length > 0 && inputs.every((input) => hostAllows(input) && filesAction(input) === undefined);
|
|
93
|
+
});
|
|
94
|
+
const action = (data: string, panel: PickerPanel): PickerAction | undefined => {
|
|
95
|
+
if (panel === "files") {
|
|
96
|
+
const native = filesAction(data);
|
|
97
|
+
if (native) return native;
|
|
98
|
+
const add = addKey();
|
|
99
|
+
if (add && matchesKey(data, add)) return "add";
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (matchesKey(data, "ctrl+c")) return "close";
|
|
103
|
+
if (keybindings.matches(data, "tui.select.cancel")) return "cancel";
|
|
104
|
+
// Match Input's ordering: cancellation, undo, then submission (including LF).
|
|
105
|
+
if (keybindings.matches(data, "tui.editor.undo")) return "input";
|
|
106
|
+
if (keybindings.matches(data, "tui.input.submit") || data === "\n") return "submit";
|
|
107
|
+
return "input";
|
|
108
|
+
};
|
|
109
|
+
const bindings = (panel: PickerPanel) => ({
|
|
110
|
+
getKeys(name: PickerAction): string[] {
|
|
111
|
+
const binding =
|
|
112
|
+
name === "mark"
|
|
113
|
+
? "tui.input.tab"
|
|
114
|
+
: name === "paths"
|
|
115
|
+
? "app.session.togglePath"
|
|
116
|
+
: name === "submit"
|
|
117
|
+
? "tui.input.submit"
|
|
118
|
+
: selection.find(([action]) => action === name)?.[1];
|
|
119
|
+
const candidates =
|
|
120
|
+
name === "markUp"
|
|
121
|
+
? (["shift+tab"] as const)
|
|
122
|
+
: name === "add"
|
|
123
|
+
? [addKey()].filter((key) => key !== undefined)
|
|
124
|
+
: binding
|
|
125
|
+
? keybindings.getKeys(binding)
|
|
126
|
+
: [];
|
|
127
|
+
const key = candidates.find((key) => {
|
|
128
|
+
const inputs = hintInputs.filter((input) => matchesKey(input, key));
|
|
129
|
+
return inputs.length > 0 && inputs.every((input) => hostAllows(input) && action(input, panel) === name);
|
|
130
|
+
});
|
|
131
|
+
return key ? [key] : [];
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const label = (name: PickerAction, text: string, panel: PickerPanel = "files") =>
|
|
135
|
+
formatInteractionHints(bindings(panel), [{ bindings: [name], label: text }]) || text;
|
|
136
|
+
const hints = (panel: PickerPanel, count: number, busy: boolean) => {
|
|
137
|
+
const keys = bindings(panel);
|
|
138
|
+
const controls = formatInteractionHints(keys, [
|
|
139
|
+
...(!busy
|
|
140
|
+
? panel === "files"
|
|
141
|
+
? [
|
|
142
|
+
{ bindings: ["mark"] as const, label: "select" },
|
|
143
|
+
{ bindings: ["apply"] as const, label: count ? "apply" : "apply (select files)" },
|
|
144
|
+
{ bindings: ["markUp"] as const, label: "select ↑" },
|
|
145
|
+
]
|
|
146
|
+
: [{ bindings: ["submit"] as const, label: "add file" }]
|
|
147
|
+
: []),
|
|
148
|
+
{ bindings: ["cancel"], excludeKeys: ["ctrl+c"], label: panel === "files" ? "cancel" : "back" },
|
|
149
|
+
...(!keys.getKeys("cancel").length && hostAllows("\x03") ? [{ keys: ["ctrl+c"], label: "close" }] : []),
|
|
150
|
+
]);
|
|
151
|
+
return panel === "files" ? `${count} selected • ${controls}` : controls;
|
|
152
|
+
};
|
|
153
|
+
return { action, hints, label };
|
|
154
|
+
}
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from "node:path";
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
fuzzyFilter,
|
|
5
|
+
type SelectItem,
|
|
6
|
+
SelectList,
|
|
7
|
+
stripTerminalSequences,
|
|
8
|
+
type TuiMouseEvent,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
12
|
+
import { displayError, displayFilePath, displayPath } from "./display.js";
|
|
13
|
+
import { ScopeInput } from "./input.js";
|
|
14
|
+
import type { ScopeTarget } from "./paths.js";
|
|
15
|
+
import { type PickerAction, type PickerPanel, pickerKeys } from "./picker-keys.js";
|
|
16
|
+
import { MAX_TARGETS } from "./state.js";
|
|
17
|
+
import { exactRows, scopeFrame } from "./ui.js";
|
|
18
|
+
|
|
19
|
+
type PickerValue =
|
|
20
|
+
| { kind: "selected"; targets: readonly ScopeTarget[] }
|
|
21
|
+
| { kind: "cancelled"; reason: "back" | "close" }
|
|
22
|
+
| { kind: "error"; error: unknown };
|
|
23
|
+
|
|
24
|
+
export interface ScopePickerOptions {
|
|
25
|
+
cwd: string;
|
|
26
|
+
targets: readonly ScopeTarget[];
|
|
27
|
+
completionLabel: string;
|
|
28
|
+
signal: AbortSignal;
|
|
29
|
+
isCurrent(): boolean;
|
|
30
|
+
discover(signal: AbortSignal): Promise<string[]>;
|
|
31
|
+
resolvePath(raw: string, signal: AbortSignal): Promise<ScopeTarget>;
|
|
32
|
+
validate(
|
|
33
|
+
paths: readonly string[],
|
|
34
|
+
signal: AbortSignal,
|
|
35
|
+
added: readonly ScopeTarget[],
|
|
36
|
+
): Promise<readonly ScopeTarget[]>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LIST_ROWS = 10;
|
|
40
|
+
function outsideCwd(cwd: string, path: string): boolean {
|
|
41
|
+
const fromCwd = relative(cwd, path);
|
|
42
|
+
return isAbsolute(fromCwd) || fromCwd === ".." || fromCwd.startsWith(`..${sep}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Button = { action: PickerAction; label: string; enabled: boolean };
|
|
46
|
+
type Pressed = { kind: "file"; id: string } | { kind: "button"; action: PickerAction } | { kind: "cancelled" };
|
|
47
|
+
|
|
48
|
+
export function showScopePicker(ctx: ExtensionCommandContext, options: ScopePickerOptions) {
|
|
49
|
+
return runCustomInteraction<PickerValue>(ctx, {
|
|
50
|
+
signal: options.signal,
|
|
51
|
+
isCurrent: options.isCurrent,
|
|
52
|
+
create: ({ tui, theme, keybindings, signal: ownerSignal, complete }) => {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const signal = AbortSignal.any([ownerSignal, controller.signal]);
|
|
55
|
+
const placeholder = (text: string) => theme.fg("dim", text);
|
|
56
|
+
const search = new ScopeInput("Search files…", placeholder);
|
|
57
|
+
const pathInput = new ScopeInput("Existing file path…", placeholder);
|
|
58
|
+
const keys = pickerKeys(tui, keybindings);
|
|
59
|
+
const checked = new Set(options.targets.map((target) => target.path));
|
|
60
|
+
const added = new Map<string, ScopeTarget>();
|
|
61
|
+
let panel: PickerPanel = "files";
|
|
62
|
+
let focused = false;
|
|
63
|
+
let fullPaths = false;
|
|
64
|
+
let paths: string[] = [];
|
|
65
|
+
let rows: SelectItem[] = [];
|
|
66
|
+
let list: SelectList;
|
|
67
|
+
let loading = true;
|
|
68
|
+
let validating: "path" | "selection" | undefined;
|
|
69
|
+
let operation: AbortController | undefined;
|
|
70
|
+
let pending = Promise.resolve();
|
|
71
|
+
let disposed = false;
|
|
72
|
+
let closing = false;
|
|
73
|
+
let error = "";
|
|
74
|
+
let notice = "";
|
|
75
|
+
let pressed: Pressed | undefined;
|
|
76
|
+
let mouseLayout:
|
|
77
|
+
| {
|
|
78
|
+
width: number;
|
|
79
|
+
input?: number;
|
|
80
|
+
rows: Map<number, string>;
|
|
81
|
+
buttons: Array<{ row: number; start: number; end: number; action: PickerAction }>;
|
|
82
|
+
}
|
|
83
|
+
| undefined;
|
|
84
|
+
// Absorb the later native click instead of reusing it as a fresh gesture.
|
|
85
|
+
const cancelPress = () => {
|
|
86
|
+
if (pressed) pressed = { kind: "cancelled" };
|
|
87
|
+
};
|
|
88
|
+
const relativePath = (path: string) =>
|
|
89
|
+
outsideCwd(options.cwd, path) ? path : relative(options.cwd, path) || ".";
|
|
90
|
+
const pathLabel = (path: string) => displayFilePath(fullPaths ? path : relativePath(path));
|
|
91
|
+
const syncFocus = () => {
|
|
92
|
+
search.input.focused = focused && panel === "files";
|
|
93
|
+
pathInput.input.focused = focused && panel === "path";
|
|
94
|
+
};
|
|
95
|
+
const current = () => {
|
|
96
|
+
if (disposed || closing || signal.aborted) return false;
|
|
97
|
+
if (options.isCurrent()) return true;
|
|
98
|
+
closing = true;
|
|
99
|
+
complete({ kind: "cancelled", reason: "close" });
|
|
100
|
+
controller.abort();
|
|
101
|
+
return false;
|
|
102
|
+
};
|
|
103
|
+
const close = (value: PickerValue) => {
|
|
104
|
+
if (!current()) return;
|
|
105
|
+
closing = true;
|
|
106
|
+
complete(value);
|
|
107
|
+
controller.abort();
|
|
108
|
+
};
|
|
109
|
+
const rebuild = (selectedId = list?.getSelectedItem()?.value) => {
|
|
110
|
+
cancelPress();
|
|
111
|
+
// Escape display identities before fuzzy matching; raw absolute IDs stay payloads.
|
|
112
|
+
const query = displayPath(search.input.getValue()).slice(1, -1);
|
|
113
|
+
rows = fuzzyFilter(paths, query, (path) => displayPath(relativePath(path)).slice(1, -1)).map((path) => ({
|
|
114
|
+
value: path,
|
|
115
|
+
label: `${checked.has(path) ? "[x]" : "[ ]"} ${outsideCwd(options.cwd, path) ? "(outside) " : ""}${pathLabel(path)}`,
|
|
116
|
+
}));
|
|
117
|
+
list = new SelectList(rows, LIST_ROWS, {
|
|
118
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
119
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
120
|
+
description: (text) => theme.fg("muted", text),
|
|
121
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
122
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
123
|
+
});
|
|
124
|
+
list.setSelectedIndex(
|
|
125
|
+
Math.max(
|
|
126
|
+
0,
|
|
127
|
+
rows.findIndex((row) => row.value === selectedId),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
const runValidation = (kind: "path" | "selection", work: (operationSignal: AbortSignal) => Promise<void>) => {
|
|
132
|
+
const owned = new AbortController();
|
|
133
|
+
const operationSignal = AbortSignal.any([signal, owned.signal]);
|
|
134
|
+
operation = owned;
|
|
135
|
+
validating = kind;
|
|
136
|
+
error = "";
|
|
137
|
+
pending = (async () => {
|
|
138
|
+
try {
|
|
139
|
+
await work(operationSignal);
|
|
140
|
+
} catch (failure) {
|
|
141
|
+
if (current() && !operationSignal.aborted) error = displayError(failure);
|
|
142
|
+
} finally {
|
|
143
|
+
if (operation === owned) {
|
|
144
|
+
operation = undefined;
|
|
145
|
+
validating = undefined;
|
|
146
|
+
}
|
|
147
|
+
if (current()) tui.requestRender();
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
};
|
|
151
|
+
const apply = () => {
|
|
152
|
+
if (!current() || loading || validating) return;
|
|
153
|
+
const selectedPaths = paths.filter((path) => checked.has(path));
|
|
154
|
+
if (!selectedPaths.length) {
|
|
155
|
+
error = "Select at least one file first.";
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
runValidation("selection", async (operationSignal) => {
|
|
159
|
+
const targets = await options.validate(selectedPaths, operationSignal, [...added.values()]);
|
|
160
|
+
if (current() && !operationSignal.aborted) close({ kind: "selected", targets });
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
const addPath = () => {
|
|
164
|
+
if (!current() || loading || validating) return;
|
|
165
|
+
const raw = pathInput.input.getValue();
|
|
166
|
+
if (!raw.length) {
|
|
167
|
+
error = "Enter an existing file path.";
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
runValidation("path", async (operationSignal) => {
|
|
171
|
+
const target = await options.resolvePath(raw, operationSignal);
|
|
172
|
+
if (!current() || operationSignal.aborted) return;
|
|
173
|
+
if (!added.has(target.path) && added.size >= MAX_TARGETS) {
|
|
174
|
+
throw new Error(`Add no more than ${MAX_TARGETS} distinct paths in one picker.`);
|
|
175
|
+
}
|
|
176
|
+
const previous = list.getSelectedItem()?.value;
|
|
177
|
+
added.set(target.path, { path: target.path, canonical: target.canonical });
|
|
178
|
+
if (!paths.includes(target.path)) paths.push(target.path);
|
|
179
|
+
checked.add(target.path);
|
|
180
|
+
pathInput.input.setValue("");
|
|
181
|
+
panel = "files";
|
|
182
|
+
syncFocus();
|
|
183
|
+
rebuild(target.path);
|
|
184
|
+
if (!rows.some((row) => row.value === target.path))
|
|
185
|
+
list.setSelectedIndex(
|
|
186
|
+
Math.max(
|
|
187
|
+
0,
|
|
188
|
+
rows.findIndex((row) => row.value === previous),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
notice = `Added ${pathLabel(target.path)}`;
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
const mark = (id: string, move = 0) => {
|
|
195
|
+
if (!current() || loading || validating) return;
|
|
196
|
+
const index = rows.findIndex((row) => row.value === id);
|
|
197
|
+
if (index < 0) return;
|
|
198
|
+
if (checked.has(id)) checked.delete(id);
|
|
199
|
+
else checked.add(id);
|
|
200
|
+
error = "";
|
|
201
|
+
notice = "";
|
|
202
|
+
// Marking stops at the result boundaries; ordinary arrow navigation still wraps.
|
|
203
|
+
const next = rows[Math.max(0, Math.min(rows.length - 1, index + move))]?.value;
|
|
204
|
+
rebuild(next);
|
|
205
|
+
};
|
|
206
|
+
const perform = (command: PickerAction) => {
|
|
207
|
+
if (command === "close") close({ kind: "cancelled", reason: "close" });
|
|
208
|
+
else if (command === "cancel") {
|
|
209
|
+
if (panel === "files") close({ kind: "cancelled", reason: "back" });
|
|
210
|
+
else {
|
|
211
|
+
operation?.abort();
|
|
212
|
+
panel = "files";
|
|
213
|
+
error = "";
|
|
214
|
+
syncFocus();
|
|
215
|
+
}
|
|
216
|
+
} else if (loading || validating) return;
|
|
217
|
+
else if (command === "apply") apply();
|
|
218
|
+
else if (command === "submit") addPath();
|
|
219
|
+
else if (command === "add") {
|
|
220
|
+
panel = "path";
|
|
221
|
+
error = "";
|
|
222
|
+
notice = "";
|
|
223
|
+
cancelPress();
|
|
224
|
+
syncFocus();
|
|
225
|
+
} else if (command === "paths") {
|
|
226
|
+
fullPaths = !fullPaths;
|
|
227
|
+
rebuild();
|
|
228
|
+
} else if (command === "mark" || command === "markUp") {
|
|
229
|
+
const id = list.getSelectedItem()?.value;
|
|
230
|
+
if (id) mark(id, command === "mark" ? 1 : -1);
|
|
231
|
+
} else if (command === "up" || command === "down" || command === "pageUp" || command === "pageDown") {
|
|
232
|
+
const index = rows.findIndex((row) => row.value === list.getSelectedItem()?.value);
|
|
233
|
+
if (index < 0) return;
|
|
234
|
+
if (command === "pageUp" || command === "pageDown")
|
|
235
|
+
list.setSelectedIndex(index + (command === "pageUp" ? -LIST_ROWS : LIST_ROWS));
|
|
236
|
+
else list.setSelectedIndex((index + (command === "up" ? -1 : 1) + rows.length) % rows.length);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const handleInput = (data: string): void => {
|
|
240
|
+
if (!current()) return;
|
|
241
|
+
cancelPress();
|
|
242
|
+
// A fragmented paste keeps its original input owner across async panel changes.
|
|
243
|
+
const activeInput = search.isPasting
|
|
244
|
+
? search
|
|
245
|
+
: pathInput.isPasting
|
|
246
|
+
? pathInput
|
|
247
|
+
: panel === "files"
|
|
248
|
+
? search
|
|
249
|
+
: pathInput;
|
|
250
|
+
const previous = activeInput.input.getValue();
|
|
251
|
+
let remaining: string | undefined;
|
|
252
|
+
activeInput.handleInput(
|
|
253
|
+
data,
|
|
254
|
+
(data) => {
|
|
255
|
+
const command = keys.action(data, panel);
|
|
256
|
+
if (command === "cancel" || command === "close") {
|
|
257
|
+
perform(command);
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (validating) return true;
|
|
261
|
+
if (!command || command === "input") return false;
|
|
262
|
+
perform(command);
|
|
263
|
+
return true;
|
|
264
|
+
},
|
|
265
|
+
!validating,
|
|
266
|
+
(tail) => {
|
|
267
|
+
remaining = tail;
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
if (!current()) return;
|
|
271
|
+
if (activeInput.input.getValue() !== previous) {
|
|
272
|
+
error = "";
|
|
273
|
+
notice = "";
|
|
274
|
+
if (activeInput === search && !loading) rebuild("");
|
|
275
|
+
}
|
|
276
|
+
// Publish the pasted query before dispatching list actions from its tail.
|
|
277
|
+
if (remaining) handleInput(remaining);
|
|
278
|
+
if (current()) tui.requestRender();
|
|
279
|
+
};
|
|
280
|
+
rebuild();
|
|
281
|
+
pending = (async () => {
|
|
282
|
+
try {
|
|
283
|
+
const discovered = await options.discover(signal);
|
|
284
|
+
if (!current()) return;
|
|
285
|
+
paths = [...new Set([...options.targets.map((target) => target.path), ...discovered])];
|
|
286
|
+
loading = false;
|
|
287
|
+
rebuild();
|
|
288
|
+
tui.requestRender();
|
|
289
|
+
} catch (failure) {
|
|
290
|
+
if (current()) close({ kind: "error", error: failure });
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
293
|
+
return {
|
|
294
|
+
get focused() {
|
|
295
|
+
return focused;
|
|
296
|
+
},
|
|
297
|
+
set focused(value: boolean) {
|
|
298
|
+
focused = value;
|
|
299
|
+
syncFocus();
|
|
300
|
+
},
|
|
301
|
+
render(width: number) {
|
|
302
|
+
const safeWidth = Math.max(1, width);
|
|
303
|
+
const activeInput = panel === "files" ? search : pathInput;
|
|
304
|
+
const pasting = search.isPasting || pathInput.isPasting;
|
|
305
|
+
const busy = loading || validating !== undefined || pasting;
|
|
306
|
+
const selectedIndex = rows.findIndex((row) => row.value === list.getSelectedItem()?.value);
|
|
307
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(LIST_ROWS / 2), rows.length - LIST_ROWS));
|
|
308
|
+
const activeRow = rows[selectedIndex];
|
|
309
|
+
const content = [...activeInput.render(safeWidth)];
|
|
310
|
+
const filesStart = content.length + 1;
|
|
311
|
+
let focusedRow = 0;
|
|
312
|
+
if (panel === "files") {
|
|
313
|
+
const listRows = loading
|
|
314
|
+
? [theme.fg("dim", "Scanning files…")]
|
|
315
|
+
: rows.length
|
|
316
|
+
? list.render(safeWidth)
|
|
317
|
+
: [theme.fg("dim", "No matching files")];
|
|
318
|
+
content.push("", ...listRows);
|
|
319
|
+
focusedRow = rows.length && !loading ? filesStart + selectedIndex - start : 0;
|
|
320
|
+
const target =
|
|
321
|
+
added.get(activeRow?.value ?? "") ?? options.targets.find((target) => target.path === activeRow?.value);
|
|
322
|
+
const preview = [
|
|
323
|
+
...(activeRow && !stripTerminalSequences(listRows[selectedIndex - start] ?? "").includes(activeRow.label)
|
|
324
|
+
? [`Path: ${pathLabel(activeRow.value)}`]
|
|
325
|
+
: []),
|
|
326
|
+
...(target && (outsideCwd(options.cwd, target.path) || outsideCwd(options.cwd, target.canonical))
|
|
327
|
+
? ["Outside the current directory."]
|
|
328
|
+
: []),
|
|
329
|
+
...(target && target.path !== target.canonical
|
|
330
|
+
? [`Canonical target: ${pathLabel(target.canonical)}`]
|
|
331
|
+
: []),
|
|
332
|
+
];
|
|
333
|
+
content.push(
|
|
334
|
+
...preview.flatMap((line) => exactRows(line, safeWidth).map((part) => theme.fg("muted", part))),
|
|
335
|
+
);
|
|
336
|
+
} else if (pathInput.input.getValue()) {
|
|
337
|
+
content.push(
|
|
338
|
+
...exactRows(`Path: ${displayPath(pathInput.input.getValue())}`, safeWidth).map((line) =>
|
|
339
|
+
theme.fg("muted", line),
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (notice) content.push(...exactRows(notice, safeWidth).map((line) => theme.fg("muted", line)));
|
|
344
|
+
if (error) content.push(...exactRows(error, safeWidth).map((line) => theme.fg("error", line)));
|
|
345
|
+
if (validating)
|
|
346
|
+
content.push(
|
|
347
|
+
theme.fg(
|
|
348
|
+
"dim",
|
|
349
|
+
operation?.signal.aborted
|
|
350
|
+
? "Finishing file check…"
|
|
351
|
+
: validating === "path"
|
|
352
|
+
? "Checking file…"
|
|
353
|
+
: "Checking selected files…",
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
const pathModeLabel = fullPaths ? "Relative paths" : "Full paths";
|
|
357
|
+
const buttons: Button[] =
|
|
358
|
+
panel === "files"
|
|
359
|
+
? [
|
|
360
|
+
{ action: "apply", label: options.completionLabel, enabled: !busy && checked.size > 0 },
|
|
361
|
+
{ action: "add", label: pasting ? "Add file…" : keys.label("add", "Add file…"), enabled: !busy },
|
|
362
|
+
{
|
|
363
|
+
action: "paths",
|
|
364
|
+
label: pasting ? pathModeLabel : keys.label("paths", pathModeLabel),
|
|
365
|
+
enabled: !busy,
|
|
366
|
+
},
|
|
367
|
+
]
|
|
368
|
+
: [
|
|
369
|
+
{ action: "cancel", label: "Back", enabled: !pasting },
|
|
370
|
+
{ action: "submit", label: "Add file", enabled: !busy && pathInput.input.getValue().length > 0 },
|
|
371
|
+
];
|
|
372
|
+
let column = 0;
|
|
373
|
+
const spans = buttons.map((button) => {
|
|
374
|
+
const text = `[ ${button.label} ]`;
|
|
375
|
+
const span = {
|
|
376
|
+
start: column,
|
|
377
|
+
end: column + visibleWidth(text),
|
|
378
|
+
action: button.action,
|
|
379
|
+
enabled: button.enabled,
|
|
380
|
+
};
|
|
381
|
+
column = span.end + 2;
|
|
382
|
+
return { ...span, text: theme.fg(button.enabled ? "muted" : "dim", text) };
|
|
383
|
+
});
|
|
384
|
+
const buttonRow = content.length;
|
|
385
|
+
content.push(spans.map((span) => span.text).join(" "));
|
|
386
|
+
const frame = scopeFrame(
|
|
387
|
+
tui,
|
|
388
|
+
theme,
|
|
389
|
+
width,
|
|
390
|
+
panel === "files" ? "Select files" : "Add existing file",
|
|
391
|
+
[`Cwd: ${displayFilePath(options.cwd)}`],
|
|
392
|
+
content,
|
|
393
|
+
[focusedRow, 0, buttonRow],
|
|
394
|
+
focusedRow,
|
|
395
|
+
[pasting ? `${checked.size} selected • Pasting…` : keys.hints(panel, checked.size, busy)],
|
|
396
|
+
);
|
|
397
|
+
const buttonFrameRow = frame.contentRows.find((row) => row.contentIndex === buttonRow)?.frameIndex;
|
|
398
|
+
mouseLayout = {
|
|
399
|
+
width,
|
|
400
|
+
input: frame.contentRows.find((row) => row.contentIndex === 0)?.frameIndex,
|
|
401
|
+
rows: new Map(
|
|
402
|
+
panel === "files"
|
|
403
|
+
? frame.contentRows.flatMap((row) => {
|
|
404
|
+
const native = row.contentIndex - filesStart;
|
|
405
|
+
const id = rows[start + native]?.value;
|
|
406
|
+
return native >= 0 && native < Math.min(LIST_ROWS, rows.length) && id ? [[row.frameIndex, id]] : [];
|
|
407
|
+
})
|
|
408
|
+
: [],
|
|
409
|
+
),
|
|
410
|
+
buttons:
|
|
411
|
+
buttonFrameRow === undefined
|
|
412
|
+
? []
|
|
413
|
+
: spans
|
|
414
|
+
.filter((span) => span.enabled && span.end <= width)
|
|
415
|
+
.map((span) => ({ row: buttonFrameRow, start: span.start, end: span.end, action: span.action })),
|
|
416
|
+
};
|
|
417
|
+
return frame.lines;
|
|
418
|
+
},
|
|
419
|
+
handleInput,
|
|
420
|
+
handleMouse(event: TuiMouseEvent) {
|
|
421
|
+
if (
|
|
422
|
+
!current() ||
|
|
423
|
+
!mouseLayout ||
|
|
424
|
+
event.width !== mouseLayout.width ||
|
|
425
|
+
search.isPasting ||
|
|
426
|
+
pathInput.isPasting
|
|
427
|
+
)
|
|
428
|
+
return;
|
|
429
|
+
// Native click follows the original press even if a render moves the rows.
|
|
430
|
+
if (event.type === "click" && event.button === "left" && pressed) {
|
|
431
|
+
const target = pressed;
|
|
432
|
+
pressed = undefined;
|
|
433
|
+
if (target.kind === "file") mark(target.id);
|
|
434
|
+
else if (target.kind === "button") perform(target.action);
|
|
435
|
+
return { handled: true, render: target.kind !== "cancelled" };
|
|
436
|
+
}
|
|
437
|
+
if (event.type === "press" && event.button === "left") pressed = undefined;
|
|
438
|
+
const button = mouseLayout.buttons.find(
|
|
439
|
+
(button) => button.row === event.y && event.x >= button.start && event.x < button.end,
|
|
440
|
+
);
|
|
441
|
+
if (button && event.button === "left") {
|
|
442
|
+
if (event.type === "press") pressed = { kind: "button", action: button.action };
|
|
443
|
+
else if (event.type === "click") perform(button.action);
|
|
444
|
+
else return;
|
|
445
|
+
return { handled: true, render: true, focus: event.type === "press" };
|
|
446
|
+
}
|
|
447
|
+
if (loading || validating) return;
|
|
448
|
+
if (event.y === mouseLayout.input) {
|
|
449
|
+
const result = (panel === "files" ? search : pathInput).handleMouse({ ...event, y: 0, height: 1 });
|
|
450
|
+
// Input edits the caret on press only; its later click must not hit a moved button.
|
|
451
|
+
if (event.type === "press" && result?.handled) pressed = { kind: "cancelled" };
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
const id = mouseLayout.rows.get(event.y);
|
|
455
|
+
if (!id) return;
|
|
456
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
457
|
+
cancelPress();
|
|
458
|
+
const selected = rows.findIndex((row) => row.value === list.getSelectedItem()?.value);
|
|
459
|
+
list.setSelectedIndex(Math.max(0, Math.min(rows.length - 1, selected + (event.wheelDelta < 0 ? -1 : 1))));
|
|
460
|
+
} else if (event.type === "press" && event.button === "left") {
|
|
461
|
+
pressed = { kind: "file", id };
|
|
462
|
+
list.setSelectedIndex(rows.findIndex((row) => row.value === id));
|
|
463
|
+
} else if (event.type === "click" && event.button === "left") mark(id);
|
|
464
|
+
else return;
|
|
465
|
+
return { handled: true, render: true, focus: event.type === "press" };
|
|
466
|
+
},
|
|
467
|
+
invalidate() {
|
|
468
|
+
cancelPress();
|
|
469
|
+
mouseLayout = undefined;
|
|
470
|
+
search.input.invalidate();
|
|
471
|
+
pathInput.input.invalidate();
|
|
472
|
+
list.invalidate();
|
|
473
|
+
},
|
|
474
|
+
waitForPending: () => pending,
|
|
475
|
+
dispose() {
|
|
476
|
+
if (disposed) return;
|
|
477
|
+
disposed = true;
|
|
478
|
+
pressed = undefined;
|
|
479
|
+
controller.abort();
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|