blots-editor 0.0.13 → 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,16 +1,24 @@
1
1
  import { Box, Text, useBoxMetrics, useCursor } from "ink";
2
- import { applyElement, applyElementToModalLayer, closeLayer, openLayer, 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";
9
11
  import { ToolBar } from "./tool-bar.js";
10
12
  /** Layer hosting the floating toolbar; below modal layers so it never
11
13
  * intercepts keys meant for a modal (e.g. the command bar). */
12
14
  const TOOLBAR_LAYER_ID = "toolbar";
13
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;
14
22
  /**
15
23
  * Main editor view: renders the status bar + line numbers + text, routes
16
24
  * every keystroke to the controller as a named command, and splits
@@ -18,17 +26,26 @@ const TOOLBAR_Z_INDEX = 1;
18
26
  * hjkl + arrow-key movement and the `:` command bar.
19
27
  */
20
28
  export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
21
- const controllerRef = useRef(null);
22
- if (!controllerRef.current) {
23
- 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, {
24
34
  indentWidth: numberOfIndentationSpaces,
25
35
  });
26
36
  }
27
- const controller = controllerRef.current;
37
+ const session = sessionRef.current;
38
+ const controller = session.controller;
28
39
  const [, forceUpdate] = useState(0);
29
40
  useEffect(() => {
30
- return controller.onChange(() => forceUpdate((n) => n + 1));
31
- }, [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]);
32
49
  const { setCursorPosition } = useCursor();
33
50
  const { boundKeyboard, enableWildcardPriority, getCurrentMode, registryCompositionKey, removeCompositionKey, setMode, } = useKeyboard();
34
51
  const { t } = useI18n();
@@ -45,8 +62,9 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
45
62
  applyElementToModalLayer("command", {
46
63
  elementId: "command-input",
47
64
  element: CommandBar,
65
+ props: { session },
48
66
  });
49
- }, []);
67
+ }, [session]);
50
68
  // The toolbar lives in a regular layer (below modal layers) so it floats
51
69
  // above the document without taking over the keyboard. The editor owns the
52
70
  // layer lifecycle and the ctrl+tab toggle because the layer's element
@@ -60,6 +78,12 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
60
78
  // dispatcher registration and openLayer would throw. ctrl+tab toggles hit
61
79
  // the same deferred path — the one-tick delay is imperceptible. Closing is
62
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);
63
87
  useEffect(() => {
64
88
  if (!toolbarOpen) {
65
89
  closeLayer(TOOLBAR_LAYER_ID);
@@ -67,14 +91,63 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
67
91
  }
68
92
  const timer = setTimeout(() => {
69
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");
70
99
  applyElement(TOOLBAR_LAYER_ID, {
71
100
  elementId: "toolbar",
72
101
  element: ToolBar,
73
- props: { mode },
102
+ props: {
103
+ mode,
104
+ session,
105
+ fileTreeOpen,
106
+ onToggleFileTree: () => setFileTreeOpen((open) => !open),
107
+ onOpenSettings: () => setSettingsOpen((open) => !open),
108
+ },
74
109
  });
75
110
  }, 0);
76
111
  return () => clearTimeout(timer);
77
- }, [toolbarOpen, mode]);
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);
150
+ }, []);
78
151
  useEffect(() => {
79
152
  return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
80
153
  }, [boundKeyboard]);
@@ -133,6 +206,8 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
133
206
  bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
134
207
  bind(["i"], () => switchMode("insert"), { mode: "normal" });
135
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" });
136
211
  return () => {
137
212
  removeWildcard();
138
213
  unbinds.forEach((fn) => fn());
@@ -146,6 +221,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
146
221
  registryCompositionKey,
147
222
  removeCompositionKey,
148
223
  switchMode,
224
+ session,
149
225
  ]);
150
226
  const ref = useRef(null);
151
227
  const { height, width } = useBoxMetrics(ref);
@@ -196,7 +272,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
196
272
  });
197
273
  const modeText = mode === "normal" ? t("editor.mode.normal") : t("editor.mode.insert");
198
274
  return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
199
- 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 }),
200
276
  React.createElement(Box, { ref: mouseRef, flexGrow: 1, width: "100%", backgroundColor: "#1e1e1e" },
201
277
  React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, Array.from({ length: effectiveH }, (_, i) => {
202
278
  const vline = visibleStart + i;
@@ -206,6 +282,6 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
206
282
  }
207
283
  return (React.createElement(Box, { key: vline, flexDirection: "row" },
208
284
  React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? (React.createElement(Text, { bold: cursor.line === seg.line }, seg.line)) : null),
209
- React.createElement(Text, null, doc.getLine(seg.line).slice(seg.start, seg.end))));
285
+ React.createElement(Text, null, seg.text)));
210
286
  })))));
211
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
  }
@@ -1,6 +1,6 @@
1
1
  import { Box, Text, useWindowSize } from "ink";
2
2
  import { useMouseRegion } from "ink-cartridge";
3
- import React, { useRef, useState } from "react";
3
+ import React, { useEffect, useRef, useState } from "react";
4
4
  import { clampFrame } from "./frame-clamp.js";
5
5
  /**
6
6
  * Shared draggable modal frame used by the language and sensitivity pickers.
@@ -18,12 +18,16 @@ export function ModalFrame({ title, children, footer, width = 36, }) {
18
18
  // Where inside the frame the cursor was grabbed, so the frame doesn't
19
19
  // jump so its top-left lands under the cursor on the first drag move.
20
20
  const grabOffsetRef = useRef({ dx: 0, dy: 0 });
21
+ // The frame's size, captured at grab time — used by the resize clamp
22
+ // below (a non-null position implies a drag happened, so this is set).
23
+ const frameSizeRef = useRef({ width: 0, height: 0 });
21
24
  const frameRef = useMouseRegion({
22
25
  onDragStart: (event, rect) => {
23
26
  grabOffsetRef.current = {
24
27
  dx: event.x - rect.x,
25
28
  dy: event.y - rect.y,
26
29
  };
30
+ frameSizeRef.current = { width: rect.width, height: rect.height };
27
31
  // Switch from flex centering to absolute positioning at the
28
32
  // current rect — seamless, no jump on the first frame.
29
33
  setPos({ top: rect.y - 1, left: rect.x - 1 });
@@ -31,12 +35,40 @@ export function ModalFrame({ title, children, footer, width = 36, }) {
31
35
  },
32
36
  onDragMove: (event, rect) => {
33
37
  const { dx, dy } = grabOffsetRef.current;
34
- setPos(clampFrame(event.x - dx - 1, event.y - dy - 1, columns, rows, rect.width, rect.height));
38
+ const clamped = clampFrame(event.x - dx - 1, event.y - dy - 1, columns, rows, rect.width, rect.height);
39
+ // The information bar occupies the top row — keep the frame below
40
+ // it, same as the toolbar.
41
+ setPos({ top: Math.max(1, clamped.top), left: clamped.left });
35
42
  },
36
43
  onDragEnd: () => setDragging(false),
37
44
  });
38
- return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "center" },
39
- React.createElement(Box, { ref: frameRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, flexDirection: "column", borderStyle: "bold", borderColor: dragging ? "green" : "white", backgroundColor: "black", width: width, alignItems: "center", paddingX: 4, paddingY: 1, gap: 1 },
45
+ // The drag clamp only runs while dragging — a terminal resize can shrink
46
+ // the viewport under a dragged frame afterwards. Re-clamp on every size
47
+ // change so the frame never extends past the edge (top stays ≥ 1 so it
48
+ // cannot overlap the information bar). The centered state (pos null) is
49
+ // flex-laid-out and follows the terminal automatically; a non-null pos
50
+ // implies a drag happened, so frameSizeRef is populated.
51
+ useEffect(() => {
52
+ const { width, height } = frameSizeRef.current;
53
+ if (!width || !height) {
54
+ return;
55
+ }
56
+ setPos((current) => {
57
+ if (!current) {
58
+ return current;
59
+ }
60
+ const clamped = clampFrame(current.left, current.top, columns, rows, width, height);
61
+ const next = { top: Math.max(1, clamped.top), left: clamped.left };
62
+ return next.left === current.left && next.top === current.top ? current : next;
63
+ });
64
+ }, [columns, rows]);
65
+ return (
66
+ // paddingTop keeps the centered frame below the information bar even
67
+ // in short terminals where centering would push it into row 1.
68
+ React.createElement(Box, { width: "100%", height: "100%", paddingTop: 1, justifyContent: "center", alignItems: "center" },
69
+ React.createElement(Box, { ref: frameRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, flexDirection: "column", borderStyle: "bold", borderColor: dragging ? "green" : "white",
70
+ // Match the editor surface so dialogs read as part of it.
71
+ backgroundColor: "#1e1e1e", borderBackgroundColor: "#1e1e1e", width: width, alignItems: "center", paddingX: 4, paddingY: 1, gap: 1 },
40
72
  React.createElement(Text, { bold: true }, title),
41
73
  children,
42
74
  footer)));