blots-editor 0.0.12 → 0.0.14
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/asset/language/en.json +16 -1
- package/asset/language/zh.json +16 -1
- package/dist/core/document/document.d.ts +14 -0
- package/dist/core/document/document.js +24 -2
- package/dist/core/document/text-line.d.ts +16 -1
- package/dist/core/document/text-line.js +36 -3
- package/dist/core/file-tree-model.d.ts +40 -0
- package/dist/core/file-tree-model.js +118 -0
- package/dist/core/session.d.ts +58 -0
- package/dist/core/session.js +116 -0
- package/dist/core/settings/schema.d.ts +13 -0
- package/dist/core/settings/schema.js +11 -0
- package/dist/core/settings/useSettings.d.ts +2 -0
- package/dist/core/settings/useSettings.js +17 -12
- package/dist/view/command-bar.d.ts +6 -1
- package/dist/view/command-bar.js +11 -2
- package/dist/view/editor-setting.d.ts +8 -0
- package/dist/view/editor-setting.js +69 -0
- package/dist/view/editor.js +122 -10
- package/dist/view/file-tree.d.ts +15 -0
- package/dist/view/file-tree.js +217 -0
- package/dist/view/information-bar.d.ts +8 -2
- package/dist/view/information-bar.js +26 -4
- package/dist/view/modal-frame.js +36 -4
- package/dist/view/settings.d.ts +11 -0
- package/dist/view/settings.js +114 -7
- package/dist/view/tool-bar.d.ts +62 -0
- package/dist/view/tool-bar.js +148 -0
- package/dist/view/toolbar-store.d.ts +19 -0
- package/dist/view/toolbar-store.js +25 -0
- package/dist/view/tree-width-store.d.ts +6 -0
- package/dist/view/tree-width-store.js +31 -0
- package/package.json +1 -1
package/dist/view/command-bar.js
CHANGED
|
@@ -6,7 +6,7 @@ import React, { useContext, useEffect, useRef, useState } from "react";
|
|
|
6
6
|
* element, so it exclusively owns the keyboard while open — the editor's
|
|
7
7
|
* bindings (including the insert-mode `*` wildcard) stay dormant.
|
|
8
8
|
*/
|
|
9
|
-
export function CommandBar() {
|
|
9
|
+
export function CommandBar({ session = null }) {
|
|
10
10
|
const ctx = useContext(ModalLayerElementContext);
|
|
11
11
|
const { t } = useI18n();
|
|
12
12
|
const { boundKeyboard, setMode } = useKeyboard();
|
|
@@ -40,6 +40,15 @@ export function CommandBar() {
|
|
|
40
40
|
setMode("insert");
|
|
41
41
|
back();
|
|
42
42
|
}
|
|
43
|
+
else if (cmd === "save") {
|
|
44
|
+
// The session records the result as an info-bar message.
|
|
45
|
+
if (session?.save().ok) {
|
|
46
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
47
|
+
}
|
|
48
|
+
else if (session) {
|
|
49
|
+
setError(session.message?.text ?? t("command.saveError"));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
43
52
|
else if (cmd) {
|
|
44
53
|
setError(t("command.unknown", { params: { cmd } }));
|
|
45
54
|
}
|
|
@@ -47,7 +56,7 @@ export function CommandBar() {
|
|
|
47
56
|
return () => {
|
|
48
57
|
unbinds.forEach((fn) => fn());
|
|
49
58
|
};
|
|
50
|
-
}, [boundKeyboard, closeModalLayer, ctx, setMode, t]);
|
|
59
|
+
}, [boundKeyboard, closeModalLayer, ctx, session, setMode, t]);
|
|
51
60
|
return (React.createElement(Box, { flexDirection: "row", width: "100%", height: 1, paddingLeft: 1, backgroundColor: "black" },
|
|
52
61
|
React.createElement(Text, { color: "green" }, ":"),
|
|
53
62
|
React.createElement(Text, null, input),
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* In-editor menu (regular layer element, opened from the toolbar's Settings
|
|
4
|
+
* button). Two mouse-clickable buttons: "Settings" opens the settings panel
|
|
5
|
+
* as its own layer on top, "Exit" navigates back to the main menu. Esc
|
|
6
|
+
* closes this layer.
|
|
7
|
+
*/
|
|
8
|
+
export declare function EditorSetting(): React.JSX.Element;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { applyElement, back, closeLayer, LayerElementContext, openLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
3
|
+
import React, { useCallback, useContext, useEffect, useState } from "react";
|
|
4
|
+
import { SettingsEntries } from "./settings.js";
|
|
5
|
+
import { ModalFrame } from "./modal-frame.js";
|
|
6
|
+
/** Layer hosting the settings panel, opened on top of the menu layer. */
|
|
7
|
+
const SETTINGS_PANEL_LAYER_ID = "settings-panel";
|
|
8
|
+
const SETTINGS_PANEL_Z_INDEX = 3;
|
|
9
|
+
/** Full-width menu button: bold border, editor background, green hover.
|
|
10
|
+
* priority 1: the button lives inside the draggable ModalFrame and must win
|
|
11
|
+
* the hit test over the overlapping frame region. */
|
|
12
|
+
function MenuButton({ label, active, onEnter, onLeave, onClick }) {
|
|
13
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
|
|
14
|
+
return (React.createElement(Box, { ref: ref, borderStyle: "bold", borderColor: active ? "green" : "white", borderBackgroundColor: "#1e1e1e", backgroundColor: "#1e1e1e", height: 3, width: "100%", justifyContent: "center", alignItems: "center" },
|
|
15
|
+
React.createElement(Text, null, label)));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The settings panel as its own layer, opened from the editor menu's
|
|
19
|
+
* "Settings" button. Same entries as the settings screen; the Back button
|
|
20
|
+
* (or Esc) closes this layer and returns to the menu layer underneath.
|
|
21
|
+
*/
|
|
22
|
+
function SettingsPanel() {
|
|
23
|
+
const ctx = useContext(LayerElementContext);
|
|
24
|
+
const { t } = useI18n();
|
|
25
|
+
const [hovered, setHovered] = useState(false);
|
|
26
|
+
const close = useCallback(() => {
|
|
27
|
+
if (ctx) {
|
|
28
|
+
closeLayer(ctx.layer.layerId);
|
|
29
|
+
}
|
|
30
|
+
}, [ctx]);
|
|
31
|
+
return (React.createElement(ModalFrame, { title: t("settings.title"), width: 44, footer: React.createElement(Text, { dimColor: true },
|
|
32
|
+
t("settings.back"),
|
|
33
|
+
" (Esc)") },
|
|
34
|
+
React.createElement(SettingsEntries, { onExit: close }),
|
|
35
|
+
React.createElement(MenuButton, { label: t("settings.back"), active: hovered, onEnter: () => setHovered(true), onLeave: () => setHovered(false), onClick: close })));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* In-editor menu (regular layer element, opened from the toolbar's Settings
|
|
39
|
+
* button). Two mouse-clickable buttons: "Settings" opens the settings panel
|
|
40
|
+
* as its own layer on top, "Exit" navigates back to the main menu. Esc
|
|
41
|
+
* closes this layer.
|
|
42
|
+
*/
|
|
43
|
+
export function EditorSetting() {
|
|
44
|
+
const ctx = useContext(LayerElementContext);
|
|
45
|
+
const { t } = useI18n();
|
|
46
|
+
const { boundKeyboard } = useKeyboard();
|
|
47
|
+
const [hovered, setHovered] = useState(null);
|
|
48
|
+
const close = useCallback(() => {
|
|
49
|
+
if (ctx) {
|
|
50
|
+
closeLayer(ctx.layer.layerId);
|
|
51
|
+
}
|
|
52
|
+
}, [ctx]);
|
|
53
|
+
const openSettings = useCallback(() => {
|
|
54
|
+
openLayer(SETTINGS_PANEL_LAYER_ID, SETTINGS_PANEL_Z_INDEX);
|
|
55
|
+
applyElement(SETTINGS_PANEL_LAYER_ID, {
|
|
56
|
+
elementId: "settings-panel",
|
|
57
|
+
element: SettingsPanel,
|
|
58
|
+
});
|
|
59
|
+
}, []);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
return boundKeyboard(["escape"], close);
|
|
62
|
+
}, [boundKeyboard, close]);
|
|
63
|
+
return (React.createElement(ModalFrame, { title: t("editor.setting.title"), width: 34 },
|
|
64
|
+
React.createElement(MenuButton, { label: t("editor.setting.openSetting"), active: hovered === "setting", onEnter: () => setHovered("setting"), onLeave: () => setHovered((h) => (h === "setting" ? null : h)), onClick: openSettings }),
|
|
65
|
+
React.createElement(MenuButton, { label: t("editor.setting.exit"), active: hovered === "exit", onEnter: () => setHovered("exit"), onLeave: () => setHovered((h) => (h === "exit" ? null : h)), onClick: () => {
|
|
66
|
+
close();
|
|
67
|
+
back();
|
|
68
|
+
} })));
|
|
69
|
+
}
|
package/dist/view/editor.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { Box, Text, useBoxMetrics, useCursor } from "ink";
|
|
2
|
-
import { applyElementToModalLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
2
|
+
import { applyElement, applyElementToModalLayer, closeLayer, eraseElement, openLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
3
3
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { EditorSession } from "../core/session.js";
|
|
5
5
|
import { useSettings } from "../core/settings/useSettings.js";
|
|
6
6
|
import { clickToPosition } from "./click-mapping.js";
|
|
7
7
|
import { CommandBar } from "./command-bar.js";
|
|
8
|
+
import { EditorSetting } from "./editor-setting.js";
|
|
9
|
+
import { FileTree } from "./file-tree.js";
|
|
8
10
|
import { InformationBar } from "./information-bar.js";
|
|
11
|
+
import { ToolBar } from "./tool-bar.js";
|
|
12
|
+
/** Layer hosting the floating toolbar; below modal layers so it never
|
|
13
|
+
* intercepts keys meant for a modal (e.g. the command bar). */
|
|
14
|
+
const TOOLBAR_LAYER_ID = "toolbar";
|
|
15
|
+
const TOOLBAR_Z_INDEX = 1;
|
|
16
|
+
/** Layer hosting the file tree; a regular layer like the toolbar. */
|
|
17
|
+
const FILETREE_LAYER_ID = "file-tree";
|
|
18
|
+
const FILETREE_Z_INDEX = 1;
|
|
19
|
+
/** Layer hosting the in-editor settings overlay (above the other panes). */
|
|
20
|
+
const EDITOR_SETTINGS_LAYER_ID = "editor-settings";
|
|
21
|
+
const EDITOR_SETTINGS_Z_INDEX = 2;
|
|
9
22
|
/**
|
|
10
23
|
* Main editor view: renders the status bar + line numbers + text, routes
|
|
11
24
|
* every keystroke to the controller as a named command, and splits
|
|
@@ -13,17 +26,26 @@ import { InformationBar } from "./information-bar.js";
|
|
|
13
26
|
* hjkl + arrow-key movement and the `:` command bar.
|
|
14
27
|
*/
|
|
15
28
|
export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
// One file session per editor mount; `Document.setText` keeps the
|
|
30
|
+
// controller instance stable across file opens, so bindings stay live.
|
|
31
|
+
const sessionRef = useRef(null);
|
|
32
|
+
if (!sessionRef.current) {
|
|
33
|
+
sessionRef.current = new EditorSession(initialText, {
|
|
19
34
|
indentWidth: numberOfIndentationSpaces,
|
|
20
35
|
});
|
|
21
36
|
}
|
|
22
|
-
const
|
|
37
|
+
const session = sessionRef.current;
|
|
38
|
+
const controller = session.controller;
|
|
23
39
|
const [, forceUpdate] = useState(0);
|
|
24
40
|
useEffect(() => {
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
const unbindDoc = controller.onChange(() => forceUpdate((n) => n + 1));
|
|
42
|
+
// File opens/saves notify the session, not the controller.
|
|
43
|
+
const unbindSession = session.onChange(() => forceUpdate((n) => n + 1));
|
|
44
|
+
return () => {
|
|
45
|
+
unbindDoc();
|
|
46
|
+
unbindSession();
|
|
47
|
+
};
|
|
48
|
+
}, [controller, session]);
|
|
27
49
|
const { setCursorPosition } = useCursor();
|
|
28
50
|
const { boundKeyboard, enableWildcardPriority, getCurrentMode, registryCompositionKey, removeCompositionKey, setMode, } = useKeyboard();
|
|
29
51
|
const { t } = useI18n();
|
|
@@ -40,8 +62,95 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
40
62
|
applyElementToModalLayer("command", {
|
|
41
63
|
elementId: "command-input",
|
|
42
64
|
element: CommandBar,
|
|
65
|
+
props: { session },
|
|
43
66
|
});
|
|
67
|
+
}, [session]);
|
|
68
|
+
// The toolbar lives in a regular layer (below modal layers) so it floats
|
|
69
|
+
// above the document without taking over the keyboard. The editor owns the
|
|
70
|
+
// layer lifecycle and the ctrl+tab toggle because the layer's element
|
|
71
|
+
// unmounts when it closes — a binding inside it could never re-open it.
|
|
72
|
+
const [toolbarOpen, setToolbarOpen] = useState(true);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
return () => closeLayer(TOOLBAR_LAYER_ID);
|
|
75
|
+
}, []);
|
|
76
|
+
// Opening must wait one tick: passive effects run child-first on mount, so
|
|
77
|
+
// when the editor IS the initial screen this effect races the provider's
|
|
78
|
+
// dispatcher registration and openLayer would throw. ctrl+tab toggles hit
|
|
79
|
+
// the same deferred path — the one-tick delay is imperceptible. Closing is
|
|
80
|
+
// safe immediately because the dispatcher is registered by then.
|
|
81
|
+
// The file tree pane (right side) toggles via the toolbar's File Tree
|
|
82
|
+
// button; it defaults to open so the editor greets the user with a tree.
|
|
83
|
+
const [fileTreeOpen, setFileTreeOpen] = useState(true);
|
|
84
|
+
// The in-editor settings overlay toggles via the toolbar's Settings
|
|
85
|
+
// button; the overlay itself closes via its Exit button or Esc.
|
|
86
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!toolbarOpen) {
|
|
89
|
+
closeLayer(TOOLBAR_LAYER_ID);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
openLayer(TOOLBAR_LAYER_ID, TOOLBAR_Z_INDEX);
|
|
94
|
+
// applyElement ignores re-applies of an existing element id, so
|
|
95
|
+
// erase first — otherwise prop updates (mode, fileTreeOpen) would
|
|
96
|
+
// never reach the mounted toolbar. The toolbar's drag position
|
|
97
|
+
// survives the remount via the external position store.
|
|
98
|
+
eraseElement(TOOLBAR_LAYER_ID, "toolbar");
|
|
99
|
+
applyElement(TOOLBAR_LAYER_ID, {
|
|
100
|
+
elementId: "toolbar",
|
|
101
|
+
element: ToolBar,
|
|
102
|
+
props: {
|
|
103
|
+
mode,
|
|
104
|
+
session,
|
|
105
|
+
fileTreeOpen,
|
|
106
|
+
onToggleFileTree: () => setFileTreeOpen((open) => !open),
|
|
107
|
+
onOpenSettings: () => setSettingsOpen((open) => !open),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}, 0);
|
|
111
|
+
return () => clearTimeout(timer);
|
|
112
|
+
}, [toolbarOpen, mode, session, fileTreeOpen, settingsOpen]);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!fileTreeOpen) {
|
|
115
|
+
closeLayer(FILETREE_LAYER_ID);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
openLayer(FILETREE_LAYER_ID, FILETREE_Z_INDEX);
|
|
120
|
+
applyElement(FILETREE_LAYER_ID, {
|
|
121
|
+
elementId: "file-tree",
|
|
122
|
+
element: FileTree,
|
|
123
|
+
props: { session },
|
|
124
|
+
});
|
|
125
|
+
}, 0);
|
|
126
|
+
return () => clearTimeout(timer);
|
|
127
|
+
}, [fileTreeOpen, session]);
|
|
128
|
+
// Both branches are deferred: settingsOpen starts false, and an immediate
|
|
129
|
+
// closeLayer on the first mount would race the provider's dispatcher
|
|
130
|
+
// registration (same child-first effect ordering as the toolbar open).
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
if (!settingsOpen) {
|
|
134
|
+
closeLayer(EDITOR_SETTINGS_LAYER_ID);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
openLayer(EDITOR_SETTINGS_LAYER_ID, EDITOR_SETTINGS_Z_INDEX);
|
|
138
|
+
applyElement(EDITOR_SETTINGS_LAYER_ID, {
|
|
139
|
+
elementId: "editor-settings",
|
|
140
|
+
element: EditorSetting,
|
|
141
|
+
});
|
|
142
|
+
}, 0);
|
|
143
|
+
return () => clearTimeout(timer);
|
|
144
|
+
}, [settingsOpen]);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
return () => closeLayer(EDITOR_SETTINGS_LAYER_ID);
|
|
147
|
+
}, []);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
return () => closeLayer(FILETREE_LAYER_ID);
|
|
44
150
|
}, []);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
|
|
153
|
+
}, [boundKeyboard]);
|
|
45
154
|
useEffect(() => {
|
|
46
155
|
const removeWildcard = enableWildcardPriority();
|
|
47
156
|
const unbinds = [];
|
|
@@ -97,6 +206,8 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
97
206
|
bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
|
|
98
207
|
bind(["i"], () => switchMode("insert"), { mode: "normal" });
|
|
99
208
|
bind([":"], () => openCommandBar(), { mode: "normal" });
|
|
209
|
+
// Ctrl+S saves in normal mode (Vim-style; insert stays untouched).
|
|
210
|
+
bind(["ctrl+s"], () => session.save(), { mode: "normal" });
|
|
100
211
|
return () => {
|
|
101
212
|
removeWildcard();
|
|
102
213
|
unbinds.forEach((fn) => fn());
|
|
@@ -110,6 +221,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
110
221
|
registryCompositionKey,
|
|
111
222
|
removeCompositionKey,
|
|
112
223
|
switchMode,
|
|
224
|
+
session,
|
|
113
225
|
]);
|
|
114
226
|
const ref = useRef(null);
|
|
115
227
|
const { height, width } = useBoxMetrics(ref);
|
|
@@ -160,7 +272,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
160
272
|
});
|
|
161
273
|
const modeText = mode === "normal" ? t("editor.mode.normal") : t("editor.mode.insert");
|
|
162
274
|
return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
|
|
163
|
-
React.createElement(InformationBar, { mode: modeText, cursor: { line: cursor.line, column: cursor.visual } }),
|
|
275
|
+
React.createElement(InformationBar, { mode: modeText, cursor: { line: cursor.line, column: cursor.visual }, session: session }),
|
|
164
276
|
React.createElement(Box, { ref: mouseRef, flexGrow: 1, width: "100%", backgroundColor: "#1e1e1e" },
|
|
165
277
|
React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, Array.from({ length: effectiveH }, (_, i) => {
|
|
166
278
|
const vline = visibleStart + i;
|
|
@@ -170,6 +282,6 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
170
282
|
}
|
|
171
283
|
return (React.createElement(Box, { key: vline, flexDirection: "row" },
|
|
172
284
|
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? (React.createElement(Text, { bold: cursor.line === seg.line }, seg.line)) : null),
|
|
173
|
-
React.createElement(Text, null,
|
|
285
|
+
React.createElement(Text, null, seg.text)));
|
|
174
286
|
})))));
|
|
175
287
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { EditorSession } from "../core/session.js";
|
|
3
|
+
export type FileTreeProps = {
|
|
4
|
+
/** The shared file session; clicking a file opens it here. */
|
|
5
|
+
session: EditorSession;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* VSCode-style file tree pinned to the right edge of the terminal (regular
|
|
9
|
+
* layer element). Recursively scans the configured root directory once per
|
|
10
|
+
* settings change; directories expand/collapse on click, files open in the
|
|
11
|
+
* editor (with an unsaved-changes prompt when the buffer is dirty). Scrolling
|
|
12
|
+
* is mouse-wheel only; the pane is fixed — not draggable — and stays below
|
|
13
|
+
* the information bar.
|
|
14
|
+
*/
|
|
15
|
+
export declare function FileTree({ session }: FileTreeProps): React.JSX.Element;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
2
|
+
import { applyElementToModalLayer, ModalLayerElementContext, openModalLayer, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
|
|
3
|
+
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import stringWidth from "string-width";
|
|
6
|
+
import { clearFileTreeCache, flattenTree, resolveFileTreeRoot, scanDirectoryCached, toggleExpanded, } from "../core/file-tree-model.js";
|
|
7
|
+
import { useSettings } from "../core/settings/useSettings.js";
|
|
8
|
+
import { ModalFrame } from "./modal-frame.js";
|
|
9
|
+
import { setTreeWidth } from "./tree-width-store.js";
|
|
10
|
+
/** Narrowest the pane can be; wide enough for short names. */
|
|
11
|
+
const MIN_TREE_WIDTH = 24;
|
|
12
|
+
/** Widest the pane can be — the editor keeps at least 20 columns. */
|
|
13
|
+
const MAX_TREE_WIDTH = 60;
|
|
14
|
+
/**
|
|
15
|
+
* VSCode-style file tree pinned to the right edge of the terminal (regular
|
|
16
|
+
* layer element). Recursively scans the configured root directory once per
|
|
17
|
+
* settings change; directories expand/collapse on click, files open in the
|
|
18
|
+
* editor (with an unsaved-changes prompt when the buffer is dirty). Scrolling
|
|
19
|
+
* is mouse-wheel only; the pane is fixed — not draggable — and stays below
|
|
20
|
+
* the information bar.
|
|
21
|
+
*/
|
|
22
|
+
export function FileTree({ session }) {
|
|
23
|
+
const { rows, columns } = useWindowSize();
|
|
24
|
+
const { settings } = useSettings();
|
|
25
|
+
const [expanded, setExpanded] = useState(() => new Set());
|
|
26
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
27
|
+
const [hoveredPath, setHoveredPath] = useState(null);
|
|
28
|
+
// scanTick bumps to force a re-scan — the cache never expires on its own.
|
|
29
|
+
const [scanTick, setScanTick] = useState(0);
|
|
30
|
+
const root = useMemo(() => {
|
|
31
|
+
// scanTick is a dependency only to force a re-scan on refresh.
|
|
32
|
+
void scanTick;
|
|
33
|
+
const resolved = resolveFileTreeRoot(settings.fileTree, process.cwd());
|
|
34
|
+
return resolved ? scanDirectoryCached(resolved) : null;
|
|
35
|
+
}, [settings.fileTree, scanTick]);
|
|
36
|
+
// A new scan (root path changed) starts collapsed at the top.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setExpanded(new Set());
|
|
39
|
+
setScrollTop(0);
|
|
40
|
+
}, [root]);
|
|
41
|
+
const visibleRows = useMemo(() => (root ? flattenTree(root, expanded) : []), [root, expanded]);
|
|
42
|
+
const handleWheel = useCallback((event) => {
|
|
43
|
+
if (event.button === "wheel-up") {
|
|
44
|
+
setScrollTop((t) => Math.max(0, t - 1));
|
|
45
|
+
}
|
|
46
|
+
else if (event.button === "wheel-down") {
|
|
47
|
+
setScrollTop((t) => t + 1);
|
|
48
|
+
}
|
|
49
|
+
}, []);
|
|
50
|
+
const openFile = useCallback((path) => {
|
|
51
|
+
if (session.isDirty()) {
|
|
52
|
+
openUnsavedPrompt(session, path);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
session.open(path);
|
|
56
|
+
}
|
|
57
|
+
}, [session]);
|
|
58
|
+
const toggleDir = useCallback((path) => {
|
|
59
|
+
setExpanded((prev) => {
|
|
60
|
+
const next = new Set(prev);
|
|
61
|
+
toggleExpanded(next, path);
|
|
62
|
+
return next;
|
|
63
|
+
});
|
|
64
|
+
}, []);
|
|
65
|
+
const containerRef = useMouseRegion({ onWheel: handleWheel });
|
|
66
|
+
// Manual refresh: the scan cache never expires on its own, so new files
|
|
67
|
+
// only appear after a click here (cache cleared + re-scan).
|
|
68
|
+
const [refreshHovered, setRefreshHovered] = useState(false);
|
|
69
|
+
const refreshRef = useMouseRegion({
|
|
70
|
+
onEnter: () => setRefreshHovered(true),
|
|
71
|
+
onLeave: () => setRefreshHovered(false),
|
|
72
|
+
onClick: () => {
|
|
73
|
+
clearFileTreeCache();
|
|
74
|
+
setScanTick((t) => t + 1);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const viewportRows = Math.max(1, rows - 4);
|
|
78
|
+
const visible = visibleRows.slice(scrollTop, scrollTop + viewportRows);
|
|
79
|
+
// Fit the pane to its widest visible line (indent + arrow + name), so
|
|
80
|
+
// long file names stay readable; capped so the editor keeps room.
|
|
81
|
+
const treeWidth = useMemo(() => {
|
|
82
|
+
const widest = visible.reduce((max, row) => Math.max(max, (row.depth - 1) * 2 + 2 + stringWidth(row.node.name)), 0);
|
|
83
|
+
const title = root ? stringWidth(basename(root.path)) : 0;
|
|
84
|
+
// +3 = row padding (1) + the two border cells.
|
|
85
|
+
const content = Math.max(widest, title) + 3;
|
|
86
|
+
return Math.min(Math.max(MIN_TREE_WIDTH, content), MAX_TREE_WIDTH);
|
|
87
|
+
}, [root, visible]);
|
|
88
|
+
// Publish the live width so the toolbar's drag clamp stays in sync
|
|
89
|
+
// (module store — the tree and toolbar are separate layer elements).
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setTreeWidth(treeWidth);
|
|
92
|
+
}, [treeWidth]);
|
|
93
|
+
return (React.createElement(Box, { ref: containerRef, position: "absolute", top: 1, left: columns - treeWidth, width: treeWidth, height: rows - 1, borderStyle: "bold", borderColor: "white",
|
|
94
|
+
// Match the editor surface so the pane reads as part of it.
|
|
95
|
+
backgroundColor: "#1e1e1e", borderBackgroundColor: "#1e1e1e", flexDirection: "column" },
|
|
96
|
+
React.createElement(Box, { paddingLeft: 1, paddingTop: 1, flexDirection: "row" },
|
|
97
|
+
React.createElement(Text, { bold: true }, root ? basename(root.path) : "-"),
|
|
98
|
+
React.createElement(Box, { ref: refreshRef, marginLeft: 1 },
|
|
99
|
+
React.createElement(Text, { color: refreshHovered ? "green" : "gray" }, "\u21BB"))),
|
|
100
|
+
root ? (visible.map((row) => (React.createElement(TreeRow, { key: row.node.path, node: row.node, depth: row.depth, expanded: expanded.has(row.node.path), hovered: hoveredPath === row.node.path, onEnter: () => setHoveredPath(row.node.path), onLeave: () => setHoveredPath((h) => (h === row.node.path ? null : h)), onWheel: handleWheel, onClick: () => {
|
|
101
|
+
if (row.node.isDir) {
|
|
102
|
+
toggleDir(row.node.path);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
openFile(row.node.path);
|
|
106
|
+
}
|
|
107
|
+
} })))) : (React.createElement(Box, { paddingLeft: 1 },
|
|
108
|
+
React.createElement(Text, { dimColor: true }, "No directory")))));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* One tree line: indent + expand arrow (dirs) + name. The arrow follows the
|
|
112
|
+
* expansion state: collapsed dirs point right, expanded ones point down
|
|
113
|
+
* (VSCode convention); empty dirs show no arrow. Wheel events are forwarded
|
|
114
|
+
* so scrolling works while the cursor is over a row — the engine delivers
|
|
115
|
+
* wheel to the top hit region only.
|
|
116
|
+
*/
|
|
117
|
+
function TreeRow({ node, depth, expanded, hovered, onEnter, onLeave, onWheel, onClick }) {
|
|
118
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick, onWheel }, { priority: 1 });
|
|
119
|
+
const hasChildren = (node.children?.length ?? 0) > 0;
|
|
120
|
+
const arrow = node.isDir
|
|
121
|
+
? hasChildren
|
|
122
|
+
? expanded
|
|
123
|
+
? "▾ "
|
|
124
|
+
: "▸ "
|
|
125
|
+
: " "
|
|
126
|
+
: " ";
|
|
127
|
+
// The root is not rendered, so depth starts at 1; each level adds one indent.
|
|
128
|
+
const indent = " ".repeat(Math.max(0, depth - 1));
|
|
129
|
+
return (React.createElement(Box, { ref: ref, paddingLeft: 1 },
|
|
130
|
+
React.createElement(Text, { inverse: hovered, bold: node.isDir },
|
|
131
|
+
indent,
|
|
132
|
+
arrow,
|
|
133
|
+
node.name)));
|
|
134
|
+
}
|
|
135
|
+
/** One button of the unsaved-changes prompt (mouse + keyboard hotkey). */
|
|
136
|
+
function PromptButton({ label, hotkey, active, onEnter, onLeave, onClick }) {
|
|
137
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
|
|
138
|
+
return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
|
|
139
|
+
React.createElement(Text, { inverse: active },
|
|
140
|
+
label,
|
|
141
|
+
" (",
|
|
142
|
+
hotkey,
|
|
143
|
+
")")));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Modal "unsaved changes" prompt with three choices (Save / Discard /
|
|
147
|
+
* Cancel), opened when a file-tree click would discard a dirty buffer.
|
|
148
|
+
* Keyboard: s / d / c (Esc also cancels); the buttons are mouse-clickable.
|
|
149
|
+
*/
|
|
150
|
+
function UnsavedPrompt({ title, onSave, onDiscard, onCancel }) {
|
|
151
|
+
const ctx = useContext(ModalLayerElementContext);
|
|
152
|
+
const { t } = useI18n();
|
|
153
|
+
const { boundKeyboard } = useKeyboard();
|
|
154
|
+
const { closeModalLayer } = useScreenSystem();
|
|
155
|
+
const [hovered, setHovered] = useState(null);
|
|
156
|
+
const close = useCallback(() => {
|
|
157
|
+
if (ctx) {
|
|
158
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
159
|
+
}
|
|
160
|
+
}, [closeModalLayer, ctx]);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!ctx) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const unbinds = [
|
|
166
|
+
boundKeyboard(["s"], () => {
|
|
167
|
+
close();
|
|
168
|
+
onSave();
|
|
169
|
+
}, { elementId: ctx.id }),
|
|
170
|
+
boundKeyboard(["d"], () => {
|
|
171
|
+
close();
|
|
172
|
+
onDiscard();
|
|
173
|
+
}, { elementId: ctx.id }),
|
|
174
|
+
boundKeyboard(["c", "escape"], () => {
|
|
175
|
+
close();
|
|
176
|
+
onCancel();
|
|
177
|
+
}, { elementId: ctx.id }),
|
|
178
|
+
];
|
|
179
|
+
return () => unbinds.forEach((fn) => fn());
|
|
180
|
+
}, [boundKeyboard, close, ctx, onCancel, onDiscard, onSave]);
|
|
181
|
+
return (React.createElement(ModalFrame, { title: title },
|
|
182
|
+
React.createElement(Box, { flexDirection: "row", gap: 2 },
|
|
183
|
+
React.createElement(PromptButton, { label: t("confirm.save"), hotkey: "s", active: hovered === "save", onEnter: () => setHovered("save"), onLeave: () => setHovered((h) => (h === "save" ? null : h)), onClick: () => {
|
|
184
|
+
close();
|
|
185
|
+
onSave();
|
|
186
|
+
} }),
|
|
187
|
+
React.createElement(PromptButton, { label: t("confirm.discard"), hotkey: "d", active: hovered === "discard", onEnter: () => setHovered("discard"), onLeave: () => setHovered((h) => (h === "discard" ? null : h)), onClick: () => {
|
|
188
|
+
close();
|
|
189
|
+
onDiscard();
|
|
190
|
+
} }),
|
|
191
|
+
React.createElement(PromptButton, { label: t("confirm.cancel"), hotkey: "c", active: hovered === "cancel", onEnter: () => setHovered("cancel"), onLeave: () => setHovered((h) => (h === "cancel" ? null : h)), onClick: () => {
|
|
192
|
+
close();
|
|
193
|
+
onCancel();
|
|
194
|
+
} }))));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Open the unsaved-changes prompt; on Save the buffer is written first and
|
|
198
|
+
* only then is the target file loaded (a failed save keeps the editor put).
|
|
199
|
+
*/
|
|
200
|
+
function openUnsavedPrompt(session, targetPath) {
|
|
201
|
+
openModalLayer("unsaved", 100);
|
|
202
|
+
applyElementToModalLayer("unsaved", {
|
|
203
|
+
elementId: "unsaved-prompt",
|
|
204
|
+
element: UnsavedPrompt,
|
|
205
|
+
props: {
|
|
206
|
+
title: "Unsaved changes",
|
|
207
|
+
onSave: () => {
|
|
208
|
+
const saved = session.save();
|
|
209
|
+
if (saved.ok) {
|
|
210
|
+
session.open(targetPath);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
onDiscard: () => session.open(targetPath),
|
|
214
|
+
onCancel: () => { },
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import type { EditorSession } from "../core/session.js";
|
|
2
3
|
export type InformationBarProps = {
|
|
3
4
|
mode: string;
|
|
4
5
|
cursor?: {
|
|
5
6
|
line: number;
|
|
6
7
|
column: number;
|
|
7
8
|
};
|
|
9
|
+
/** File session: drives the file name, dirty marker, and save messages. */
|
|
10
|
+
session?: EditorSession | null;
|
|
8
11
|
};
|
|
9
|
-
/**
|
|
10
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Status bar: current mode, cursor position, and (with a session) the open
|
|
14
|
+
* file name with a dirty marker plus the last save/open result message.
|
|
15
|
+
*/
|
|
16
|
+
export declare function InformationBar({ mode, cursor, session }: InformationBarProps): React.JSX.Element;
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
|
-
import React from "react";
|
|
3
|
-
/**
|
|
4
|
-
|
|
2
|
+
import React, { useEffect, useState } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Status bar: current mode, cursor position, and (with a session) the open
|
|
5
|
+
* file name with a dirty marker plus the last save/open result message.
|
|
6
|
+
*/
|
|
7
|
+
export function InformationBar({ mode, cursor, session = null }) {
|
|
8
|
+
// Session changes (file open, save) don't rerender the editor's doc —
|
|
9
|
+
// subscribe here so the right side of the bar stays live.
|
|
10
|
+
const [, forceUpdate] = useState(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!session) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
return session.onChange(() => forceUpdate((n) => n + 1));
|
|
16
|
+
}, [session]);
|
|
17
|
+
const dirty = session?.isDirty() ?? false;
|
|
18
|
+
const message = session?.message ?? null;
|
|
5
19
|
return (React.createElement(Box, { width: "100%", height: 1, backgroundColor: "blue", flexDirection: "row", paddingLeft: 2 },
|
|
6
20
|
React.createElement(Text, { color: "white", bold: true }, mode),
|
|
7
21
|
cursor ? (React.createElement(Text, { color: "white" },
|
|
@@ -9,5 +23,13 @@ export function InformationBar({ mode, cursor }) {
|
|
|
9
23
|
"Ln ",
|
|
10
24
|
cursor.line + 1,
|
|
11
25
|
", Col ",
|
|
12
|
-
cursor.column + 1)) : null
|
|
26
|
+
cursor.column + 1)) : null,
|
|
27
|
+
session ? (React.createElement(Text, { color: "white" },
|
|
28
|
+
" ",
|
|
29
|
+
session.displayName,
|
|
30
|
+
dirty ? " ●" : "")) : null,
|
|
31
|
+
message ? (React.createElement(React.Fragment, null,
|
|
32
|
+
React.createElement(Box, { flexGrow: 1 }),
|
|
33
|
+
React.createElement(Box, { marginRight: 2 },
|
|
34
|
+
React.createElement(Text, { color: message.kind === "error" ? "red" : "green" }, message.text)))) : null));
|
|
13
35
|
}
|