blots-editor 0.0.13 → 0.0.15

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.
Files changed (53) hide show
  1. package/asset/language/en.json +19 -1
  2. package/asset/language/zh.json +19 -1
  3. package/dist/core/document/document.d.ts +14 -0
  4. package/dist/core/document/document.js +24 -2
  5. package/dist/core/document/text-line.d.ts +16 -1
  6. package/dist/core/document/text-line.js +36 -3
  7. package/dist/core/io/file-tree-model.d.ts +49 -0
  8. package/dist/core/io/file-tree-model.js +129 -0
  9. package/dist/core/io/session.d.ts +58 -0
  10. package/dist/core/io/session.js +116 -0
  11. package/dist/core/settings/schema.d.ts +13 -0
  12. package/dist/core/settings/schema.js +11 -0
  13. package/dist/core/settings/useSettings.d.ts +2 -0
  14. package/dist/core/settings/useSettings.js +17 -12
  15. package/dist/index.js +4 -4
  16. package/dist/{view → utils/view}/click-mapping.d.ts +1 -1
  17. package/dist/{view → utils/view}/frame-clamp.d.ts +4 -0
  18. package/dist/{view → utils/view}/frame-clamp.js +8 -0
  19. package/dist/{i18n-resources.js → utils/view/i18n-resources.js} +1 -1
  20. package/dist/view/editor/command-bar.d.ts +12 -0
  21. package/dist/view/{command-bar.js → editor/command-bar.js} +11 -2
  22. package/dist/view/editor/editor-setting.d.ts +8 -0
  23. package/dist/view/editor/editor-setting.js +69 -0
  24. package/dist/view/editor/file-tree.d.ts +19 -0
  25. package/dist/view/editor/file-tree.js +235 -0
  26. package/dist/view/editor/information-bar.d.ts +16 -0
  27. package/dist/view/editor/information-bar.js +35 -0
  28. package/dist/view/editor/tool-bar.d.ts +36 -0
  29. package/dist/view/editor/tool-bar.js +134 -0
  30. package/dist/view/event/subscription/toolbar-store.d.ts +18 -0
  31. package/dist/view/event/subscription/toolbar-store.js +25 -0
  32. package/dist/view/event/subscription/tree-store.d.ts +10 -0
  33. package/dist/view/event/subscription/tree-store.js +28 -0
  34. package/dist/view/{editor.js → page/editor.js} +94 -18
  35. package/dist/view/{main-menu.js → page/main-menu.js} +1 -1
  36. package/dist/view/page/settings.d.ts +13 -0
  37. package/dist/view/{settings.js → page/settings.js} +117 -10
  38. package/dist/view/utils/modal-frame.js +75 -0
  39. package/package.json +1 -1
  40. package/dist/view/command-bar.d.ts +0 -7
  41. package/dist/view/information-bar.d.ts +0 -10
  42. package/dist/view/information-bar.js +0 -13
  43. package/dist/view/modal-frame.js +0 -43
  44. package/dist/view/settings.d.ts +0 -2
  45. package/dist/view/tool-bar.d.ts +0 -51
  46. package/dist/view/tool-bar.js +0 -92
  47. /package/dist/{view → utils/view}/click-mapping.js +0 -0
  48. /package/dist/{i18n-resources.d.ts → utils/view/i18n-resources.d.ts} +0 -0
  49. /package/dist/view/{editor.d.ts → page/editor.d.ts} +0 -0
  50. /package/dist/view/{main-menu.d.ts → page/main-menu.d.ts} +0 -0
  51. /package/dist/view/{modal-frame.d.ts → utils/modal-frame.d.ts} +0 -0
  52. /package/dist/view/{sensitivity-bar.d.ts → utils/sensitivity-bar.d.ts} +0 -0
  53. /package/dist/view/{sensitivity-bar.js → utils/sensitivity-bar.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { useSyncExternalStore } from "react";
1
+ import { useMemo, useSyncExternalStore } from "react";
2
2
  import { SettingsStore } from "./store.js";
3
3
  /**
4
4
  * Shared settings store for the whole editor. One instance serves the
@@ -13,15 +13,20 @@ export const settingsStore = new SettingsStore();
13
13
  */
14
14
  export function useSettings() {
15
15
  const settings = useSyncExternalStore(settingsStore.subscribe, () => settingsStore.settings, () => settingsStore.settings);
16
- const next = (key, value) => ({
17
- ...settings,
18
- wheel: { ...settings.wheel, [key]: value },
19
- });
20
- return {
21
- settings,
22
- setSensitivity: (key, value) => settingsStore.persist(next(key, value)),
23
- setDraft: (key, value) => settingsStore.update(next(key, value)),
24
- commit: () => settingsStore.commit(),
25
- setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
26
- };
16
+ // Memoized per settings snapshot so the returned functions keep stable
17
+ // identities across renders — effect deps that reference them stay calm.
18
+ return useMemo(() => {
19
+ const next = (key, value) => ({
20
+ ...settings,
21
+ wheel: { ...settings.wheel, [key]: value },
22
+ });
23
+ return {
24
+ settings,
25
+ setSensitivity: (key, value) => settingsStore.persist(next(key, value)),
26
+ setDraft: (key, value) => settingsStore.update(next(key, value)),
27
+ commit: () => settingsStore.commit(),
28
+ setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
29
+ setFileTree: (fileTree) => settingsStore.persist({ ...settings, fileTree }),
30
+ };
31
+ }, [settings]);
27
32
  }
package/dist/index.js CHANGED
@@ -2,11 +2,11 @@
2
2
  import { render } from "ink";
3
3
  import React from "react";
4
4
  import { CurrentScreen, KeyboardProvider, LanguageProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
5
- import { resources } from "./i18n-resources.js";
5
+ import { resources } from "./utils/view/i18n-resources.js";
6
6
  import { settingsStore } from "./core/settings/useSettings.js";
7
- import { MainMenu } from "./view/main-menu.js";
8
- import { Editor } from "./view/editor.js";
9
- import { Settings } from "./view/settings.js";
7
+ import { MainMenu } from "./view/page/main-menu.js";
8
+ import { Editor } from "./view/page/editor.js";
9
+ import { Settings } from "./view/page/settings.js";
10
10
  registerComponent(MainMenu, {});
11
11
  registerComponent(Editor, {}, { parent: MainMenu });
12
12
  registerComponent(Settings, {}, { parent: MainMenu });
@@ -1,4 +1,4 @@
1
- import type { Document } from "../core/document/document.js";
1
+ import type { Document } from "../../core/document/document.js";
2
2
  export type ClickTarget = {
3
3
  line: number;
4
4
  logical: number;
@@ -8,3 +8,7 @@ export declare function clampFrame(left: number, top: number, columns: number, r
8
8
  left: number;
9
9
  top: number;
10
10
  };
11
+ export declare function clampBarPos(left: number, top: number, width: number, height: number, columns: number, rows: number, fileTreeOpen: boolean, treeWidth: number): {
12
+ left: number;
13
+ top: number;
14
+ };
@@ -10,3 +10,11 @@ export function clampFrame(left, top, columns, rows, width, height) {
10
10
  top: Math.min(Math.max(0, top), Math.max(0, rows - height)),
11
11
  };
12
12
  }
13
+ export function clampBarPos(left, top, width, height, columns, rows, fileTreeOpen, treeWidth) {
14
+ const clamped = clampFrame(left, top, columns, rows, width, height);
15
+ const maxLeft = fileTreeOpen ? columns - treeWidth - width : columns - width;
16
+ return {
17
+ left: Math.min(Math.max(0, clamped.left), Math.max(0, maxLeft)),
18
+ top: Math.max(1, clamped.top),
19
+ };
20
+ }
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
8
8
  * global installs work regardless of cwd — the JSON files ship inside the
9
9
  * package via the `asset` entry in `files`.
10
10
  */
11
- const LANGUAGE_DIR = new URL("../asset/language/", import.meta.url);
11
+ const LANGUAGE_DIR = new URL("../../../asset/language/", import.meta.url);
12
12
  function loadPack(locale) {
13
13
  return JSON.parse(readFileSync(new URL(`${locale}.json`, LANGUAGE_DIR), "utf8"));
14
14
  }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import type { EditorSession } from "../../core/io/session.js";
3
+ export type CommandBarProps = {
4
+ /** File session used by `:save`. */
5
+ session?: EditorSession | null;
6
+ };
7
+ /**
8
+ * Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
9
+ * element, so it exclusively owns the keyboard while open — the editor's
10
+ * bindings (including the insert-mode `*` wildcard) stay dormant.
11
+ */
12
+ export declare function CommandBar({ session }: CommandBarProps): React.JSX.Element;
@@ -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 "../page/settings.js";
5
+ import { ModalFrame } from "../utils/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
+ }
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import type { FileTreeState } from "../../core/io/file-tree-model.js";
3
+ import type { EditorSession } from "../../core/io/session.js";
4
+ export type FileTreeProps = {
5
+ /** The shared file session; clicking a file opens it here. */
6
+ session: EditorSession;
7
+ };
8
+ export type FileTreeDirState = FileTreeState | {
9
+ noDir?: boolean;
10
+ };
11
+ /**
12
+ * VSCode-style file tree pinned to the right edge of the terminal (regular
13
+ * layer element). Recursively scans the configured root directory once per
14
+ * settings change; directories expand/collapse on click, files open in the
15
+ * editor (with an unsaved-changes prompt when the buffer is dirty). Scrolling
16
+ * is mouse-wheel only; the pane is fixed — not draggable — and stays below
17
+ * the information bar.
18
+ */
19
+ export declare function FileTree({ session }: FileTreeProps): React.JSX.Element;
@@ -0,0 +1,235 @@
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/io/file-tree-model.js";
7
+ import { useSettings } from "../../core/settings/useSettings.js";
8
+ import { ModalFrame } from "../utils/modal-frame.js";
9
+ import { setTreePos } from "../event/subscription/tree-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 { t } = useI18n();
26
+ const [expanded, setExpanded] = useState(() => new Set());
27
+ const [scrollTop, setScrollTop] = useState(0);
28
+ const [hoveredPath, setHoveredPath] = useState(null);
29
+ // scanTick bumps to force a re-scan — the cache never expires on its own.
30
+ const [scanTick, setScanTick] = useState(0);
31
+ const [root, setRoot] = useState({
32
+ scanning: true,
33
+ });
34
+ const [prevRoot, setPrevRoot] = useState(root);
35
+ useEffect(() => {
36
+ setRoot({ scanning: true });
37
+ (async () => {
38
+ const resolved = resolveFileTreeRoot(settings.fileTree, process.cwd());
39
+ const tree = resolved
40
+ ? await scanDirectoryCached(resolved)
41
+ : { noDir: true };
42
+ setRoot(tree);
43
+ })();
44
+ }, [settings.fileTree, scanTick]);
45
+ // A new scan (root changed) starts collapsed at the top — reset during
46
+ // render so the next frame already renders with the reset state.
47
+ if (prevRoot !== root) {
48
+ setPrevRoot(root);
49
+ setExpanded(new Set());
50
+ setScrollTop(0);
51
+ }
52
+ const visibleRows = useMemo(() => ('node' in root ? flattenTree(root.node, expanded) : []), [root, expanded]);
53
+ const handleWheel = useCallback((event) => {
54
+ if (event.button === "wheel-up") {
55
+ setScrollTop((t) => Math.max(0, t - 1));
56
+ }
57
+ else if (event.button === "wheel-down") {
58
+ setScrollTop((t) => t + 1);
59
+ }
60
+ }, []);
61
+ const openFile = useCallback((path) => {
62
+ if (session.isDirty()) {
63
+ openUnsavedPrompt(session, path, t("fileTree.unsaved"));
64
+ }
65
+ else {
66
+ session.open(path);
67
+ }
68
+ }, [session, t]);
69
+ const toggleDir = useCallback((path) => {
70
+ setExpanded((prev) => {
71
+ const next = new Set(prev);
72
+ toggleExpanded(next, path);
73
+ return next;
74
+ });
75
+ }, []);
76
+ const containerRef = useMouseRegion({ onWheel: handleWheel });
77
+ // Manual refresh: the scan cache never expires on its own, so new files
78
+ // only appear after a click here (cache cleared + re-scan).
79
+ const [refreshHovered, setRefreshHovered] = useState(false);
80
+ const refreshRef = useMouseRegion({
81
+ onEnter: () => setRefreshHovered(true),
82
+ onLeave: () => setRefreshHovered(false),
83
+ onClick: () => {
84
+ clearFileTreeCache();
85
+ setScanTick((t) => t + 1);
86
+ },
87
+ });
88
+ const viewportRows = Math.max(1, rows - 4);
89
+ const visible = visibleRows.slice(scrollTop, scrollTop + viewportRows);
90
+ // Fit the pane to its widest visible line (indent + arrow + name), so
91
+ // long file names stay readable; capped so the editor keeps room.
92
+ const treeWidth = useMemo(() => {
93
+ const widest = visible.reduce((max, row) => Math.max(max, (row.depth - 1) * 2 + 2 + stringWidth(row.node.name)), 0);
94
+ const title = "node" in root ? stringWidth(basename(root.path)) : 0;
95
+ // +3 = row padding (1) + the two border cells.
96
+ const content = Math.max(widest, title) + 3;
97
+ return Math.min(Math.max(MIN_TREE_WIDTH, content), MAX_TREE_WIDTH);
98
+ }, [root, visible]);
99
+ // Publish the live width so the toolbar's drag clamp stays in sync
100
+ // (module store — the tree and toolbar are separate layer elements).
101
+ useEffect(() => {
102
+ setTreePos({
103
+ y: 1,
104
+ x: columns - treeWidth,
105
+ width: treeWidth,
106
+ height: rows - 1,
107
+ });
108
+ }, [treeWidth, columns, rows]);
109
+ return (React.createElement(Box, { ref: containerRef, position: "absolute", top: 1, left: columns - treeWidth, width: treeWidth, height: rows - 1, borderStyle: "bold", borderColor: "white",
110
+ // Match the editor surface so the pane reads as part of it.
111
+ backgroundColor: "#1e1e1e", borderBackgroundColor: "#1e1e1e", flexDirection: "column" },
112
+ React.createElement(Box, { paddingLeft: 1, paddingTop: 1, flexDirection: "row" },
113
+ React.createElement(Text, { bold: true }, "node" in root ? basename(root.path) : "-"),
114
+ React.createElement(Box, { ref: refreshRef, marginLeft: 1 },
115
+ React.createElement(Text, { color: refreshHovered ? "green" : "gray" }, "\u21BB"))),
116
+ "scanning" in root ? (React.createElement(Box, { paddingLeft: 1 },
117
+ React.createElement(Text, { dimColor: true }, t("fileTree.scanning")))) : "noDir" in root ? (React.createElement(Box, { paddingLeft: 1 },
118
+ React.createElement(Text, { dimColor: true }, t("fileTree.noDir")))) : "fail" in root ? (React.createElement(Box, { paddingLeft: 1 },
119
+ React.createElement(Text, { dimColor: true }, t("fileTree.fail")))) : (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: () => {
120
+ if (row.node.isDir) {
121
+ toggleDir(row.node.path);
122
+ }
123
+ else {
124
+ openFile(row.node.path);
125
+ }
126
+ } }))))));
127
+ }
128
+ /**
129
+ * One tree line: indent + expand arrow (dirs) + name. The arrow follows the
130
+ * expansion state: collapsed dirs point right, expanded ones point down
131
+ * (VSCode convention); empty dirs show no arrow. Wheel events are forwarded
132
+ * so scrolling works while the cursor is over a row — the engine delivers
133
+ * wheel to the top hit region only.
134
+ */
135
+ function TreeRow({ node, depth, expanded, hovered, onEnter, onLeave, onWheel, onClick, }) {
136
+ const ref = useMouseRegion({ onEnter, onLeave, onClick, onWheel }, { priority: 1 });
137
+ const hasChildren = (node.children?.length ?? 0) > 0;
138
+ const arrow = node.isDir
139
+ ? hasChildren
140
+ ? expanded
141
+ ? "▾ "
142
+ : "▸ "
143
+ : " "
144
+ : " ";
145
+ // The root is not rendered, so depth starts at 1; each level adds one indent.
146
+ const indent = " ".repeat(Math.max(0, depth - 1));
147
+ return (React.createElement(Box, { ref: ref, paddingLeft: 1 },
148
+ React.createElement(Text, { inverse: hovered, bold: node.isDir },
149
+ indent,
150
+ arrow,
151
+ node.name)));
152
+ }
153
+ /** One button of the unsaved-changes prompt (mouse + keyboard hotkey). */
154
+ function PromptButton({ label, hotkey, active, onEnter, onLeave, onClick, }) {
155
+ const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
156
+ return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
157
+ React.createElement(Text, { inverse: active },
158
+ label,
159
+ " (",
160
+ hotkey,
161
+ ")")));
162
+ }
163
+ /**
164
+ * Modal "unsaved changes" prompt with three choices (Save / Discard /
165
+ * Cancel), opened when a file-tree click would discard a dirty buffer.
166
+ * Keyboard: s / d / c (Esc also cancels); the buttons are mouse-clickable.
167
+ */
168
+ function UnsavedPrompt({ title, onSave, onDiscard, onCancel, }) {
169
+ const ctx = useContext(ModalLayerElementContext);
170
+ const { t } = useI18n();
171
+ const { boundKeyboard } = useKeyboard();
172
+ const { closeModalLayer } = useScreenSystem();
173
+ const [hovered, setHovered] = useState(null);
174
+ const close = useCallback(() => {
175
+ if (ctx) {
176
+ closeModalLayer(ctx.modalLayer.layerId);
177
+ }
178
+ }, [closeModalLayer, ctx]);
179
+ useEffect(() => {
180
+ if (!ctx) {
181
+ return;
182
+ }
183
+ const unbinds = [
184
+ boundKeyboard(["s"], () => {
185
+ close();
186
+ onSave();
187
+ }, { elementId: ctx.id }),
188
+ boundKeyboard(["d"], () => {
189
+ close();
190
+ onDiscard();
191
+ }, { elementId: ctx.id }),
192
+ boundKeyboard(["c", "escape"], () => {
193
+ close();
194
+ onCancel();
195
+ }, { elementId: ctx.id }),
196
+ ];
197
+ return () => unbinds.forEach((fn) => fn());
198
+ }, [boundKeyboard, close, ctx, onCancel, onDiscard, onSave]);
199
+ return (React.createElement(ModalFrame, { title: title },
200
+ React.createElement(Box, { flexDirection: "row", gap: 2 },
201
+ React.createElement(PromptButton, { label: t("confirm.save"), hotkey: "s", active: hovered === "save", onEnter: () => setHovered("save"), onLeave: () => setHovered((h) => (h === "save" ? null : h)), onClick: () => {
202
+ close();
203
+ onSave();
204
+ } }),
205
+ React.createElement(PromptButton, { label: t("confirm.discard"), hotkey: "d", active: hovered === "discard", onEnter: () => setHovered("discard"), onLeave: () => setHovered((h) => (h === "discard" ? null : h)), onClick: () => {
206
+ close();
207
+ onDiscard();
208
+ } }),
209
+ React.createElement(PromptButton, { label: t("confirm.cancel"), hotkey: "c", active: hovered === "cancel", onEnter: () => setHovered("cancel"), onLeave: () => setHovered((h) => (h === "cancel" ? null : h)), onClick: () => {
210
+ close();
211
+ onCancel();
212
+ } }))));
213
+ }
214
+ /**
215
+ * Open the unsaved-changes prompt; on Save the buffer is written first and
216
+ * only then is the target file loaded (a failed save keeps the editor put).
217
+ */
218
+ function openUnsavedPrompt(session, targetPath, title) {
219
+ openModalLayer("unsaved", 100);
220
+ applyElementToModalLayer("unsaved", {
221
+ elementId: "unsaved-prompt",
222
+ element: UnsavedPrompt,
223
+ props: {
224
+ title,
225
+ onSave: () => {
226
+ const saved = session.save();
227
+ if (saved.ok) {
228
+ session.open(targetPath);
229
+ }
230
+ },
231
+ onDiscard: () => session.open(targetPath),
232
+ onCancel: () => { },
233
+ },
234
+ });
235
+ }
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ import type { EditorSession } from "../../core/io/session.js";
3
+ export type InformationBarProps = {
4
+ mode: string;
5
+ cursor?: {
6
+ line: number;
7
+ column: number;
8
+ };
9
+ /** File session: drives the file name, dirty marker, and save messages. */
10
+ session?: EditorSession | null;
11
+ };
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;
@@ -0,0 +1,35 @@
1
+ import { Box, Text } from "ink";
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;
19
+ return (React.createElement(Box, { width: "100%", height: 1, backgroundColor: "blue", flexDirection: "row", paddingLeft: 2 },
20
+ React.createElement(Text, { color: "white", bold: true }, mode),
21
+ cursor ? (React.createElement(Text, { color: "white" },
22
+ " ",
23
+ "Ln ",
24
+ cursor.line + 1,
25
+ ", Col ",
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));
35
+ }
@@ -0,0 +1,36 @@
1
+ import { XtermMouseEvent } from "@cartridge-engine/keyboard-engine";
2
+ import React from "react";
3
+ import { EditorSession } from "../../core/io/session.js";
4
+ /**
5
+ * Props for a single tool button.
6
+ *
7
+ * @prop priority - Hit-test priority over whatever sits underneath (the editor
8
+ * surface). Buttons must win so clicking them does not also place the cursor.
9
+ * @prop onClick - Called on mouse click and on Enter selection in normal
10
+ * mode; the event is absent for the keyboard path.
11
+ * @prop text - The text to display on the button.
12
+ * @prop focused - Keyboard focus (normal-mode navigation); overrides hover.
13
+ */
14
+ export type ToolButtonProps = {
15
+ priority: number;
16
+ onClick: (event?: XtermMouseEvent) => void;
17
+ onDragStart: (event: XtermMouseEvent) => void;
18
+ onDragMove: (event: XtermMouseEvent) => void;
19
+ onDragEnd: (event: XtermMouseEvent) => void;
20
+ text: string;
21
+ focused?: boolean;
22
+ };
23
+ export type ToolBarProps = {
24
+ currentMode: string | null;
25
+ session: EditorSession;
26
+ openFileTree: () => void;
27
+ openSettings: () => void;
28
+ fileTreeOpen: boolean;
29
+ };
30
+ export type ToolButton = {
31
+ onClick: (event?: XtermMouseEvent) => void;
32
+ text: string;
33
+ priority: number;
34
+ id: string;
35
+ };
36
+ export declare function ToolBar({ currentMode, session, openFileTree, openSettings, fileTreeOpen, }: ToolBarProps): React.JSX.Element;