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.
@@ -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
+ }
@@ -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 { EditorController } from "../core/editor-controller.js";
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
- const controllerRef = useRef(null);
17
- if (!controllerRef.current) {
18
- controllerRef.current = new EditorController(initialText, {
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 controller = controllerRef.current;
37
+ const session = sessionRef.current;
38
+ const controller = session.controller;
23
39
  const [, forceUpdate] = useState(0);
24
40
  useEffect(() => {
25
- return controller.onChange(() => forceUpdate((n) => n + 1));
26
- }, [controller]);
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, doc.getLine(seg.line).slice(seg.start, seg.end))));
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
- /** Status bar: current mode (live) plus the cursor's 1-based position. */
10
- export declare function InformationBar({ mode, cursor }: InformationBarProps): React.JSX.Element;
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
- /** Status bar: current mode (live) plus the cursor's 1-based position. */
4
- export function InformationBar({ mode, cursor }) {
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
  }