blots-editor 0.0.14 → 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 (41) hide show
  1. package/asset/language/en.json +4 -0
  2. package/asset/language/zh.json +4 -0
  3. package/dist/core/{file-tree-model.d.ts → io/file-tree-model.d.ts} +12 -3
  4. package/dist/core/{file-tree-model.js → io/file-tree-model.js} +23 -12
  5. package/dist/core/{session.d.ts → io/session.d.ts} +1 -1
  6. package/dist/core/{session.js → io/session.js} +1 -1
  7. package/dist/index.js +4 -4
  8. package/dist/{view → utils/view}/click-mapping.d.ts +1 -1
  9. package/dist/{view → utils/view}/frame-clamp.d.ts +4 -0
  10. package/dist/{view → utils/view}/frame-clamp.js +8 -0
  11. package/dist/{i18n-resources.js → utils/view/i18n-resources.js} +1 -1
  12. package/dist/view/{command-bar.d.ts → editor/command-bar.d.ts} +1 -1
  13. package/dist/view/{editor-setting.js → editor/editor-setting.js} +2 -2
  14. package/dist/view/{file-tree.d.ts → editor/file-tree.d.ts} +5 -1
  15. package/dist/view/{file-tree.js → editor/file-tree.js} +46 -28
  16. package/dist/view/{information-bar.d.ts → editor/information-bar.d.ts} +1 -1
  17. package/dist/view/editor/tool-bar.d.ts +36 -0
  18. package/dist/view/editor/tool-bar.js +134 -0
  19. package/dist/view/{toolbar-store.d.ts → event/subscription/toolbar-store.d.ts} +5 -6
  20. package/dist/view/event/subscription/tree-store.d.ts +10 -0
  21. package/dist/view/{tree-width-store.js → event/subscription/tree-store.js} +9 -12
  22. package/dist/view/{editor.js → page/editor.js} +12 -12
  23. package/dist/view/{main-menu.js → page/main-menu.js} +1 -1
  24. package/dist/view/{settings.js → page/settings.js} +3 -3
  25. package/dist/view/{modal-frame.js → utils/modal-frame.js} +1 -1
  26. package/package.json +1 -1
  27. package/dist/view/tool-bar.d.ts +0 -62
  28. package/dist/view/tool-bar.js +0 -148
  29. package/dist/view/tree-width-store.d.ts +0 -6
  30. /package/dist/{view → utils/view}/click-mapping.js +0 -0
  31. /package/dist/{i18n-resources.d.ts → utils/view/i18n-resources.d.ts} +0 -0
  32. /package/dist/view/{command-bar.js → editor/command-bar.js} +0 -0
  33. /package/dist/view/{editor-setting.d.ts → editor/editor-setting.d.ts} +0 -0
  34. /package/dist/view/{information-bar.js → editor/information-bar.js} +0 -0
  35. /package/dist/view/{toolbar-store.js → event/subscription/toolbar-store.js} +0 -0
  36. /package/dist/view/{editor.d.ts → page/editor.d.ts} +0 -0
  37. /package/dist/view/{main-menu.d.ts → page/main-menu.d.ts} +0 -0
  38. /package/dist/view/{settings.d.ts → page/settings.d.ts} +0 -0
  39. /package/dist/view/{modal-frame.d.ts → utils/modal-frame.d.ts} +0 -0
  40. /package/dist/view/{sensitivity-bar.d.ts → utils/sensitivity-bar.d.ts} +0 -0
  41. /package/dist/view/{sensitivity-bar.js → utils/sensitivity-bar.js} +0 -0
@@ -19,6 +19,10 @@
19
19
  "toolbar.fileTree": "File Tree",
20
20
  "toolbar.save": "Save",
21
21
  "toolbar.settings": "Settings",
22
+ "fileTree.scanning": "Scanning...",
23
+ "fileTree.noDir": "No directory",
24
+ "fileTree.fail": "Scan failed",
25
+ "fileTree.unsaved": "Unsaved changes",
22
26
  "editor.setting.title": "Menu",
23
27
  "editor.setting.openSetting": "Settings",
24
28
  "editor.setting.exit": "Exit",
@@ -19,6 +19,10 @@
19
19
  "toolbar.fileTree": "文件树",
20
20
  "toolbar.save": "保存",
21
21
  "toolbar.settings": "设置",
22
+ "fileTree.scanning": "正在扫描...",
23
+ "fileTree.noDir": "没有目录",
24
+ "fileTree.fail": "扫描失败",
25
+ "fileTree.unsaved": "未保存的更改",
22
26
  "editor.setting.title": "菜单",
23
27
  "editor.setting.openSetting": "设置",
24
28
  "editor.setting.exit": "退出",
@@ -1,8 +1,17 @@
1
- import type { FileTreeSettings } from "./settings/schema.js";
1
+ import type { FileTreeSettings } from "../settings/schema.js";
2
2
  /** Clear the scan cache (refresh button, tests). */
3
3
  export declare function clearFileTreeCache(): void;
4
+ export type FileTreeState = {
5
+ ok: true;
6
+ node: FileNode;
7
+ path: string;
8
+ } | {
9
+ fail: true;
10
+ } | {
11
+ scanning: true;
12
+ };
4
13
  /** Scan a directory, serving repeat calls for the same root from the cache. */
5
- export declare function scanDirectoryCached(root: string): FileNode | null;
14
+ export declare function scanDirectoryCached(root: string): Promise<FileTreeState>;
6
15
  /** One entry of the scanned tree. Directories carry their children. */
7
16
  export type FileNode = {
8
17
  /** Entry name (not the full path). */
@@ -24,7 +33,7 @@ export declare function resolveFileTreeRoot(settings: FileTreeSettings, startupD
24
33
  *
25
34
  * @returns The root node, or null when the directory cannot be read.
26
35
  */
27
- export declare function scanDirectory(root: string): FileNode | null;
36
+ export declare function scanDirectory(root: string): Promise<FileNode | null>;
28
37
  /** One visible tree row produced by flattening with expansion state. */
29
38
  export type TreeRow = {
30
39
  node: FileNode;
@@ -1,4 +1,4 @@
1
- import { readdirSync } from "node:fs";
1
+ import fs from 'node:fs/promises';
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  /**
@@ -13,16 +13,27 @@ export function clearFileTreeCache() {
13
13
  scanCache.clear();
14
14
  }
15
15
  /** Scan a directory, serving repeat calls for the same root from the cache. */
16
- export function scanDirectoryCached(root) {
16
+ export async function scanDirectoryCached(root) {
17
17
  const hit = scanCache.get(root);
18
18
  if (hit) {
19
- return hit;
19
+ return {
20
+ ok: true,
21
+ node: hit,
22
+ path: root
23
+ };
20
24
  }
21
- const tree = scanDirectory(root);
25
+ const tree = await scanDirectory(root);
22
26
  if (tree) {
23
27
  scanCache.set(root, tree);
28
+ return {
29
+ ok: true,
30
+ node: tree,
31
+ path: root
32
+ };
24
33
  }
25
- return tree;
34
+ return {
35
+ fail: true
36
+ };
26
37
  }
27
38
  /** Directories skipped at every depth (dependency dirs, VCS internals). */
28
39
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".next", ".cache"]);
@@ -47,8 +58,8 @@ export function resolveFileTreeRoot(settings, startupDir) {
47
58
  *
48
59
  * @returns The root node, or null when the directory cannot be read.
49
60
  */
50
- export function scanDirectory(root) {
51
- const entries = readDirSafe(root);
61
+ export async function scanDirectory(root) {
62
+ const entries = await readDirSafe(root);
52
63
  if (!entries) {
53
64
  return null;
54
65
  }
@@ -63,7 +74,7 @@ export function scanDirectory(root) {
63
74
  if (SKIP_DIRS.has(name)) {
64
75
  continue;
65
76
  }
66
- nodes.push({ name, path, isDir: true, children: scanChildren(path) });
77
+ nodes.push({ name, path, isDir: true, children: await scanChildren(path) });
67
78
  }
68
79
  else if (entry.isFile()) {
69
80
  nodes.push({ name, path, isDir: false });
@@ -74,14 +85,14 @@ export function scanDirectory(root) {
74
85
  nodes.sort((a, b) => a.isDir === b.isDir ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.isDir ? -1 : 1);
75
86
  return { name: root, path: root, isDir: true, children: nodes };
76
87
  }
77
- function scanChildren(dir) {
78
- const child = scanDirectory(dir);
88
+ async function scanChildren(dir) {
89
+ const child = await scanDirectory(dir);
79
90
  return child?.children;
80
91
  }
81
92
  /** Read a directory without throwing; null when unreadable (EACCES etc.). */
82
- function readDirSafe(path) {
93
+ async function readDirSafe(path) {
83
94
  try {
84
- return readdirSync(path, { withFileTypes: true });
95
+ return await fs.readdir(path, { withFileTypes: true });
85
96
  }
86
97
  catch {
87
98
  return null;
@@ -1,4 +1,4 @@
1
- import { EditorController, type EditorOptions } from "./editor-controller.js";
1
+ import { EditorController, type EditorOptions } from "../editor-controller.js";
2
2
  export type ActionResult = {
3
3
  ok: true;
4
4
  } | {
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename } from "node:path";
4
- import { EditorController } from "./editor-controller.js";
4
+ import { EditorController } from "../editor-controller.js";
5
5
  /**
6
6
  * File-session coordinator shared by the editor, file tree, and toolbar.
7
7
  *
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
  }
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import type { EditorSession } from "../core/session.js";
2
+ import type { EditorSession } from "../../core/io/session.js";
3
3
  export type CommandBarProps = {
4
4
  /** File session used by `:save`. */
5
5
  session?: EditorSession | null;
@@ -1,8 +1,8 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { applyElement, back, closeLayer, LayerElementContext, openLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
3
3
  import React, { useCallback, useContext, useEffect, useState } from "react";
4
- import { SettingsEntries } from "./settings.js";
5
- import { ModalFrame } from "./modal-frame.js";
4
+ import { SettingsEntries } from "../page/settings.js";
5
+ import { ModalFrame } from "../utils/modal-frame.js";
6
6
  /** Layer hosting the settings panel, opened on top of the menu layer. */
7
7
  const SETTINGS_PANEL_LAYER_ID = "settings-panel";
8
8
  const SETTINGS_PANEL_Z_INDEX = 3;
@@ -1,9 +1,13 @@
1
1
  import React from "react";
2
- import type { EditorSession } from "../core/session.js";
2
+ import type { FileTreeState } from "../../core/io/file-tree-model.js";
3
+ import type { EditorSession } from "../../core/io/session.js";
3
4
  export type FileTreeProps = {
4
5
  /** The shared file session; clicking a file opens it here. */
5
6
  session: EditorSession;
6
7
  };
8
+ export type FileTreeDirState = FileTreeState | {
9
+ noDir?: boolean;
10
+ };
7
11
  /**
8
12
  * VSCode-style file tree pinned to the right edge of the terminal (regular
9
13
  * layer element). Recursively scans the configured root directory once per
@@ -1,12 +1,12 @@
1
1
  import { Box, Text, useWindowSize } from "ink";
2
2
  import { applyElementToModalLayer, ModalLayerElementContext, openModalLayer, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
3
- import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
3
+ import React, { useCallback, useContext, useEffect, useMemo, useState, } from "react";
4
4
  import { basename } from "node:path";
5
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";
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
10
  /** Narrowest the pane can be; wide enough for short names. */
11
11
  const MIN_TREE_WIDTH = 24;
12
12
  /** Widest the pane can be — the editor keeps at least 20 columns. */
@@ -22,23 +22,34 @@ const MAX_TREE_WIDTH = 60;
22
22
  export function FileTree({ session }) {
23
23
  const { rows, columns } = useWindowSize();
24
24
  const { settings } = useSettings();
25
+ const { t } = useI18n();
25
26
  const [expanded, setExpanded] = useState(() => new Set());
26
27
  const [scrollTop, setScrollTop] = useState(0);
27
28
  const [hoveredPath, setHoveredPath] = useState(null);
28
29
  // scanTick bumps to force a re-scan — the cache never expires on its own.
29
30
  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.
31
+ const [root, setRoot] = useState({
32
+ scanning: true,
33
+ });
34
+ const [prevRoot, setPrevRoot] = useState(root);
37
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);
38
49
  setExpanded(new Set());
39
50
  setScrollTop(0);
40
- }, [root]);
41
- const visibleRows = useMemo(() => (root ? flattenTree(root, expanded) : []), [root, expanded]);
51
+ }
52
+ const visibleRows = useMemo(() => ('node' in root ? flattenTree(root.node, expanded) : []), [root, expanded]);
42
53
  const handleWheel = useCallback((event) => {
43
54
  if (event.button === "wheel-up") {
44
55
  setScrollTop((t) => Math.max(0, t - 1));
@@ -49,12 +60,12 @@ export function FileTree({ session }) {
49
60
  }, []);
50
61
  const openFile = useCallback((path) => {
51
62
  if (session.isDirty()) {
52
- openUnsavedPrompt(session, path);
63
+ openUnsavedPrompt(session, path, t("fileTree.unsaved"));
53
64
  }
54
65
  else {
55
66
  session.open(path);
56
67
  }
57
- }, [session]);
68
+ }, [session, t]);
58
69
  const toggleDir = useCallback((path) => {
59
70
  setExpanded((prev) => {
60
71
  const next = new Set(prev);
@@ -80,7 +91,7 @@ export function FileTree({ session }) {
80
91
  // long file names stay readable; capped so the editor keeps room.
81
92
  const treeWidth = useMemo(() => {
82
93
  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;
94
+ const title = "node" in root ? stringWidth(basename(root.path)) : 0;
84
95
  // +3 = row padding (1) + the two border cells.
85
96
  const content = Math.max(widest, title) + 3;
86
97
  return Math.min(Math.max(MIN_TREE_WIDTH, content), MAX_TREE_WIDTH);
@@ -88,24 +99,31 @@ export function FileTree({ session }) {
88
99
  // Publish the live width so the toolbar's drag clamp stays in sync
89
100
  // (module store — the tree and toolbar are separate layer elements).
90
101
  useEffect(() => {
91
- setTreeWidth(treeWidth);
92
- }, [treeWidth]);
102
+ setTreePos({
103
+ y: 1,
104
+ x: columns - treeWidth,
105
+ width: treeWidth,
106
+ height: rows - 1,
107
+ });
108
+ }, [treeWidth, columns, rows]);
93
109
  return (React.createElement(Box, { ref: containerRef, position: "absolute", top: 1, left: columns - treeWidth, width: treeWidth, height: rows - 1, borderStyle: "bold", borderColor: "white",
94
110
  // Match the editor surface so the pane reads as part of it.
95
111
  backgroundColor: "#1e1e1e", borderBackgroundColor: "#1e1e1e", flexDirection: "column" },
96
112
  React.createElement(Box, { paddingLeft: 1, paddingTop: 1, flexDirection: "row" },
97
- React.createElement(Text, { bold: true }, root ? basename(root.path) : "-"),
113
+ React.createElement(Text, { bold: true }, "node" in root ? basename(root.path) : "-"),
98
114
  React.createElement(Box, { ref: refreshRef, marginLeft: 1 },
99
115
  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: () => {
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: () => {
101
120
  if (row.node.isDir) {
102
121
  toggleDir(row.node.path);
103
122
  }
104
123
  else {
105
124
  openFile(row.node.path);
106
125
  }
107
- } })))) : (React.createElement(Box, { paddingLeft: 1 },
108
- React.createElement(Text, { dimColor: true }, "No directory")))));
126
+ } }))))));
109
127
  }
110
128
  /**
111
129
  * One tree line: indent + expand arrow (dirs) + name. The arrow follows the
@@ -114,7 +132,7 @@ export function FileTree({ session }) {
114
132
  * so scrolling works while the cursor is over a row — the engine delivers
115
133
  * wheel to the top hit region only.
116
134
  */
117
- function TreeRow({ node, depth, expanded, hovered, onEnter, onLeave, onWheel, onClick }) {
135
+ function TreeRow({ node, depth, expanded, hovered, onEnter, onLeave, onWheel, onClick, }) {
118
136
  const ref = useMouseRegion({ onEnter, onLeave, onClick, onWheel }, { priority: 1 });
119
137
  const hasChildren = (node.children?.length ?? 0) > 0;
120
138
  const arrow = node.isDir
@@ -133,7 +151,7 @@ function TreeRow({ node, depth, expanded, hovered, onEnter, onLeave, onWheel, on
133
151
  node.name)));
134
152
  }
135
153
  /** One button of the unsaved-changes prompt (mouse + keyboard hotkey). */
136
- function PromptButton({ label, hotkey, active, onEnter, onLeave, onClick }) {
154
+ function PromptButton({ label, hotkey, active, onEnter, onLeave, onClick, }) {
137
155
  const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
138
156
  return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
139
157
  React.createElement(Text, { inverse: active },
@@ -147,7 +165,7 @@ function PromptButton({ label, hotkey, active, onEnter, onLeave, onClick }) {
147
165
  * Cancel), opened when a file-tree click would discard a dirty buffer.
148
166
  * Keyboard: s / d / c (Esc also cancels); the buttons are mouse-clickable.
149
167
  */
150
- function UnsavedPrompt({ title, onSave, onDiscard, onCancel }) {
168
+ function UnsavedPrompt({ title, onSave, onDiscard, onCancel, }) {
151
169
  const ctx = useContext(ModalLayerElementContext);
152
170
  const { t } = useI18n();
153
171
  const { boundKeyboard } = useKeyboard();
@@ -197,13 +215,13 @@ function UnsavedPrompt({ title, onSave, onDiscard, onCancel }) {
197
215
  * Open the unsaved-changes prompt; on Save the buffer is written first and
198
216
  * only then is the target file loaded (a failed save keeps the editor put).
199
217
  */
200
- function openUnsavedPrompt(session, targetPath) {
218
+ function openUnsavedPrompt(session, targetPath, title) {
201
219
  openModalLayer("unsaved", 100);
202
220
  applyElementToModalLayer("unsaved", {
203
221
  elementId: "unsaved-prompt",
204
222
  element: UnsavedPrompt,
205
223
  props: {
206
- title: "Unsaved changes",
224
+ title,
207
225
  onSave: () => {
208
226
  const saved = session.save();
209
227
  if (saved.ok) {
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import type { EditorSession } from "../core/session.js";
2
+ import type { EditorSession } from "../../core/io/session.js";
3
3
  export type InformationBarProps = {
4
4
  mode: string;
5
5
  cursor?: {
@@ -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;
@@ -0,0 +1,134 @@
1
+ import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
2
+ import React, { useCallback, useEffect, useMemo, useRef } from "react";
3
+ import { useState } from "react";
4
+ import { Box, measureElement, Text, useWindowSize } from "ink";
5
+ import { useTree } from "../event/subscription/tree-store.js";
6
+ import { getBarPosition, setBarPosition, useBarPosition, } from "../event/subscription/toolbar-store.js";
7
+ import { clampBarPos } from "../../utils/view/frame-clamp.js";
8
+ function ToolButton({ priority, onClick, text, focused = false, onDragStart, onDragMove, onDragEnd, }) {
9
+ const [flush, setFlush] = useState(false);
10
+ const timerRef = React.useRef(null);
11
+ useEffect(() => {
12
+ return () => {
13
+ if (timerRef.current) {
14
+ clearTimeout(timerRef.current);
15
+ }
16
+ };
17
+ }, []);
18
+ const ref = useMouseRegion({
19
+ onClick: (event) => {
20
+ setFlush(true);
21
+ if (timerRef.current) {
22
+ clearTimeout(timerRef.current);
23
+ }
24
+ timerRef.current = setTimeout(() => {
25
+ setFlush(false);
26
+ }, 150);
27
+ onClick(event);
28
+ },
29
+ onDragEnd,
30
+ onDragMove,
31
+ onDragStart,
32
+ }, { priority });
33
+ return (React.createElement(Box, { ref: ref, borderColor: flush ? "whiteBright" : focused ? "blue" : "white", borderStyle: "bold",
34
+ // Match the editor's surface so the floating bar reads as part of it.
35
+ backgroundColor: "#1e1e1e", height: "100%", justifyContent: "center", alignItems: "center", borderBackgroundColor: "#1e1e1e", paddingLeft: 2, paddingRight: 2 },
36
+ React.createElement(Text, null, text)));
37
+ }
38
+ export function ToolBar({ currentMode, session, openFileTree, openSettings, fileTreeOpen, }) {
39
+ const { t } = useI18n();
40
+ const { boundKeyboard } = useKeyboard();
41
+ const [index, setIndex] = useState(0);
42
+ const items = useMemo(() => [
43
+ {
44
+ id: "save",
45
+ onClick: () => {
46
+ session.save();
47
+ },
48
+ text: t("toolbar.save"),
49
+ priority: 1,
50
+ },
51
+ {
52
+ id: "file-tree",
53
+ onClick: () => {
54
+ openFileTree();
55
+ },
56
+ text: t("toolbar.fileTree"),
57
+ priority: 1,
58
+ },
59
+ {
60
+ id: "settings",
61
+ onClick: () => {
62
+ openSettings();
63
+ },
64
+ text: t("toolbar.settings"),
65
+ priority: 1,
66
+ },
67
+ ], [session, t, openFileTree, openSettings]);
68
+ const { rows, columns } = useWindowSize();
69
+ const pos = useBarPosition();
70
+ const treePos = useTree();
71
+ const barRef = useRef(null);
72
+ // Grab offset (where inside the bar the cursor went down) plus the bar's
73
+ // size at grab time, kept in a ref so drag moves need no re-measurement.
74
+ const grabRef = useRef({ dx: 0, dy: 0, width: 0, height: 0 });
75
+ const treeWidth = treePos.width;
76
+ const startDrag = useCallback((event) => {
77
+ const node = barRef.current;
78
+ if (!node) {
79
+ return;
80
+ }
81
+ // Measure the bar itself, not the hit region — a button may be the
82
+ // captured target, and its rect would put the bar's corner at the
83
+ // button's corner.
84
+ const m = measureElement(node);
85
+ grabRef.current = {
86
+ dx: event.x - m.x - 1,
87
+ dy: event.y - m.y - 1,
88
+ width: m.width,
89
+ height: m.height,
90
+ };
91
+ setBarPosition({ top: m.y, left: m.x });
92
+ }, []);
93
+ const moveDrag = useCallback((event) => {
94
+ const { dx, dy, width, height } = grabRef.current;
95
+ setBarPosition(clampBarPos(event.x - dx - 1, event.y - dy - 1, width, height, columns, rows, fileTreeOpen, treeWidth));
96
+ }, [columns, rows, fileTreeOpen, treeWidth]);
97
+ const endDrag = useCallback(() => { }, []);
98
+ // Re-clamp when a layout input changes (resize, tree toggled): a bar
99
+ // positioned earlier can end up outside the allowed region.
100
+ useEffect(() => {
101
+ const node = barRef.current;
102
+ if (!node) {
103
+ return;
104
+ }
105
+ const m = measureElement(node);
106
+ const current = getBarPosition();
107
+ if (!current) {
108
+ return;
109
+ }
110
+ const next = clampBarPos(current.left, current.top, m.width, m.height, columns, rows, fileTreeOpen, treeWidth);
111
+ if (next.left !== current.left || next.top !== current.top) {
112
+ setBarPosition(next);
113
+ }
114
+ }, [columns, rows, fileTreeOpen, treeWidth]);
115
+ useEffect(() => {
116
+ const unbinds = [
117
+ boundKeyboard(["ctrl+left"], () => {
118
+ setIndex((prev) => (prev - 1 + items.length) % items.length);
119
+ }),
120
+ boundKeyboard(["ctrl+right"], () => {
121
+ setIndex((prev) => (prev + 1) % items.length);
122
+ }),
123
+ boundKeyboard(["return"], () => {
124
+ items[index].onClick();
125
+ }),
126
+ ];
127
+ return () => {
128
+ unbinds.forEach((unbind) => unbind());
129
+ };
130
+ }, [boundKeyboard, index, items]);
131
+ const showFocus = currentMode === "normal";
132
+ return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "flex-end" },
133
+ React.createElement(Box, { ref: barRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, height: 3, flexDirection: "row", justifyContent: "center", alignItems: "center" }, items.map((item, itemIndex) => (React.createElement(ToolButton, { key: item.id, onClick: item.onClick, text: item.text, priority: item.priority, focused: showFocus && index === itemIndex, onDragStart: startDrag, onDragMove: moveDrag, onDragEnd: endDrag }))))));
134
+ }
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Module-level drag position of the floating toolbar.
3
- *
4
- * The toolbar element is erased and re-applied whenever its props change
5
- * (the layer system treats re-applies of an existing element id as no-ops),
6
- * which would otherwise reset the component's internal position state. An
7
- * external store survives those remounts, so a dragged bar keeps its spot.
2
+ * Module-level drag position of the floating toolbar. null = the bar sits
3
+ * at the bottom-center via flex layout; once the user grabs it, absolute
4
+ * positioning takes over so it follows the cursor. An external store
5
+ * survives the layer system's erase/re-apply remounts, so a dragged bar
6
+ * keeps its spot.
8
7
  */
9
8
  type BarPosition = {
10
9
  top: number;
@@ -0,0 +1,10 @@
1
+ export type Rect = {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ };
7
+ export declare function getTreePos(): Rect;
8
+ export declare function setTreePos(newPos: Rect): void;
9
+ /** Reactive binding: re-renders whenever the pane width changes. */
10
+ export declare function useTree(): Rect;
@@ -5,7 +5,7 @@ import { useSyncExternalStore } from "react";
5
5
  * The tree sizes itself to its content, so its width is dynamic; the toolbar
6
6
  * must not overlap it, so it reads the live width instead of a constant.
7
7
  */
8
- let width = 32;
8
+ let pos = { x: 0, y: 0, width: 0, height: 0 };
9
9
  const listeners = new Set();
10
10
  function subscribe(listener) {
11
11
  listeners.add(listener);
@@ -13,19 +13,16 @@ function subscribe(listener) {
13
13
  listeners.delete(listener);
14
14
  };
15
15
  }
16
- /** Imperative read for drag handlers. */
17
- export function getTreeWidth() {
18
- return width;
16
+ export function getTreePos() {
17
+ return pos;
19
18
  }
20
- /** Update the reported pane width (no-op when unchanged). */
21
- export function setTreeWidth(next) {
22
- if (next === width) {
23
- return;
19
+ export function setTreePos(newPos) {
20
+ pos = newPos;
21
+ for (const listener of listeners) {
22
+ listener();
24
23
  }
25
- width = next;
26
- listeners.forEach((fn) => fn());
27
24
  }
28
25
  /** Reactive binding: re-renders whenever the pane width changes. */
29
- export function useTreeWidth() {
30
- return useSyncExternalStore(subscribe, getTreeWidth, getTreeWidth);
26
+ export function useTree() {
27
+ return useSyncExternalStore(subscribe, getTreePos, getTreePos);
31
28
  }
@@ -1,14 +1,14 @@
1
1
  import { Box, Text, useBoxMetrics, useCursor } from "ink";
2
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 { EditorSession } from "../core/session.js";
5
- import { useSettings } from "../core/settings/useSettings.js";
6
- import { clickToPosition } from "./click-mapping.js";
7
- import { CommandBar } from "./command-bar.js";
8
- import { EditorSetting } from "./editor-setting.js";
9
- import { FileTree } from "./file-tree.js";
10
- import { InformationBar } from "./information-bar.js";
11
- import { ToolBar } from "./tool-bar.js";
4
+ import { EditorSession } from "../../core/io/session.js";
5
+ import { useSettings } from "../../core/settings/useSettings.js";
6
+ import { clickToPosition } from "../../utils/view/click-mapping.js";
7
+ import { CommandBar } from "../editor/command-bar.js";
8
+ import { EditorSetting } from "../editor/editor-setting.js";
9
+ import { FileTree } from "../editor/file-tree.js";
10
+ import { ToolBar } from "../editor/tool-bar.js";
11
+ import { InformationBar } from "../editor/information-bar.js";
12
12
  /** Layer hosting the floating toolbar; below modal layers so it never
13
13
  * intercepts keys meant for a modal (e.g. the command bar). */
14
14
  const TOOLBAR_LAYER_ID = "toolbar";
@@ -100,11 +100,11 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
100
100
  elementId: "toolbar",
101
101
  element: ToolBar,
102
102
  props: {
103
- mode,
103
+ currentMode: mode,
104
104
  session,
105
- fileTreeOpen,
106
- onToggleFileTree: () => setFileTreeOpen((open) => !open),
107
- onOpenSettings: () => setSettingsOpen((open) => !open),
105
+ openFileTree: () => setFileTreeOpen((open) => !open),
106
+ openSettings: () => setSettingsOpen((open) => !open),
107
+ fileTreeOpen
108
108
  },
109
109
  });
110
110
  }, 0);
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
4
4
  import { Editor } from "./editor.js";
5
5
  import { Settings } from "./settings.js";
6
6
  import { gotoScreen } from "ink-cartridge";
7
- import { getLogo } from "./logo.js";
7
+ import { getLogo } from "../logo.js";
8
8
  /**
9
9
  * A borderless text button: the plain Box provides the mouse hit area,
10
10
  * hovering inverts the text as visual feedback, disabled buttons dim.
@@ -1,9 +1,9 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { back, ModalLayerElementContext, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
3
3
  import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
4
- import { useSettings } from "../core/settings/useSettings.js";
5
- import { ModalFrame } from "./modal-frame.js";
6
- import { SensitivityBar, snapSensitivity } from "./sensitivity-bar.js";
4
+ import { useSettings } from "../../core/settings/useSettings.js";
5
+ import { ModalFrame } from "../utils/modal-frame.js";
6
+ import { SensitivityBar, snapSensitivity } from "../utils/sensitivity-bar.js";
7
7
  const LANGUAGES = [
8
8
  { code: "en", labelKey: "settings.language.en" },
9
9
  { code: "zh", labelKey: "settings.language.zh" },
@@ -1,7 +1,7 @@
1
1
  import { Box, Text, useWindowSize } from "ink";
2
2
  import { useMouseRegion } from "ink-cartridge";
3
3
  import React, { useEffect, useRef, useState } from "react";
4
- import { clampFrame } from "./frame-clamp.js";
4
+ import { clampFrame } from "../../utils/view/frame-clamp.js";
5
5
  /**
6
6
  * Shared draggable modal frame used by the language and sensitivity pickers.
7
7
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blots-editor",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,62 +0,0 @@
1
- import React from "react";
2
- import type { XtermMouseEvent } from "@cartridge-engine/keyboard-engine";
3
- import type { EditorSession } from "../core/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
- * @prop onDragStart/onDragMove/onDragEnd - Forwarded from the toolbar. The
14
- * engine captures a drag on the pressed region only, so buttons must carry
15
- * the drag handlers or dragging from a button would not move the bar.
16
- * @prop dragging - The bar is being dragged; buttons tint red.
17
- */
18
- export type ToolButtonProps = {
19
- priority: number;
20
- onClick: (event?: XtermMouseEvent) => void;
21
- text: string;
22
- focused?: boolean;
23
- onDragStart?: (event: XtermMouseEvent) => void;
24
- onDragMove?: (event: XtermMouseEvent) => void;
25
- onDragEnd?: (event: XtermMouseEvent) => void;
26
- dragging?: boolean;
27
- };
28
- /** One tool entry; the list is the extension point for future tools. */
29
- export type Tool = {
30
- id: string;
31
- /** i18n key for the button label. */
32
- labelKey: string;
33
- onClick: (event?: XtermMouseEvent) => void;
34
- };
35
- export type ToolBarProps = {
36
- /** Current keyboard mode, passed down by the editor; drives focus display. */
37
- mode?: string | null;
38
- /** Tools to render, centered in the bar. Defaults to save + file tree. */
39
- tools?: Tool[];
40
- /** File session wired to the default Save tool. */
41
- session?: EditorSession | null;
42
- /** Toggles the file-tree pane (default File Tree tool). */
43
- onToggleFileTree?: () => void;
44
- /** Opens/closes the in-editor settings layer (default Settings tool). */
45
- onOpenSettings?: () => void;
46
- /** Whether the file-tree pane is open; the bar stays left of it. */
47
- fileTreeOpen?: boolean;
48
- };
49
- /**
50
- * Floating toolbar, bottom-center of the terminal until the user drags it.
51
- * Dragging switches it to absolute positioning and clamps it inside the
52
- * terminal view (clampFrame, top ≥ 1 to preserve the information bar).
53
- * Purely presentational for now: one centered button with hover/focus states.
54
- * The editor owns the layer that hosts this bar (and the ctrl+tab toggle);
55
- * this component only adds the normal-mode keyboard navigation
56
- * (ctrl+left/ctrl+right move the focus ring, enter selects). The activator
57
- * is bare Enter, not ctrl+enter: terminals send the same byte (`\r`) for
58
- * both, so a ctrl-modified Enter can never reach the engine. Insert mode
59
- * stays mouse-only because these bindings are mode-restricted and never
60
- * fire there.
61
- */
62
- export declare function ToolBar({ mode, tools, session, onToggleFileTree, onOpenSettings, fileTreeOpen, }: ToolBarProps): React.JSX.Element;
@@ -1,148 +0,0 @@
1
- import { Box, Text, measureElement, useWindowSize } from "ink";
2
- import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
3
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { clampFrame } from "./frame-clamp.js";
5
- import { getBarPosition, setBarPosition, useBarPosition } from "./toolbar-store.js";
6
- import { useTreeWidth } from "./tree-width-store.js";
7
- /**
8
- * Clamp the bar's top-left: inside the terminal, below the information bar,
9
- * and — while the file tree is open — left of the tree pane.
10
- */
11
- function clampBarPos(left, top, width, height, columns, rows, fileTreeOpen, treeWidth) {
12
- const clamped = clampFrame(left, top, columns, rows, width, height);
13
- const maxLeft = fileTreeOpen ? columns - treeWidth - width : columns - width;
14
- return {
15
- left: Math.min(Math.max(0, clamped.left), Math.max(0, maxLeft)),
16
- top: Math.max(1, clamped.top),
17
- };
18
- }
19
- function ToolButton({ priority, onClick, text, focused = false, onDragStart, onDragMove, onDragEnd, dragging = false, }) {
20
- const [hover, setHover] = useState(false);
21
- const ref = useMouseRegion({
22
- onClick: (event) => onClick(event),
23
- onEnter: () => setHover(true),
24
- onLeave: () => setHover(false),
25
- onDragStart,
26
- onDragMove,
27
- onDragEnd,
28
- }, { priority });
29
- // Dragging outranks everything (the bar is moving), then keyboard focus,
30
- // then the transient hover tint.
31
- const borderColor = dragging ? "red" : focused ? "blue" : hover ? "green" : "white";
32
- return (React.createElement(Box, { ref: ref, borderColor: borderColor, borderStyle: "bold",
33
- // Match the editor's surface so the floating bar reads as part of it.
34
- backgroundColor: "#1e1e1e", height: "100%", justifyContent: "center", alignItems: "center", borderBackgroundColor: "#1e1e1e", paddingLeft: 2, paddingRight: 2 },
35
- React.createElement(Text, null, text)));
36
- }
37
- /**
38
- * Floating toolbar, bottom-center of the terminal until the user drags it.
39
- * Dragging switches it to absolute positioning and clamps it inside the
40
- * terminal view (clampFrame, top ≥ 1 to preserve the information bar).
41
- * Purely presentational for now: one centered button with hover/focus states.
42
- * The editor owns the layer that hosts this bar (and the ctrl+tab toggle);
43
- * this component only adds the normal-mode keyboard navigation
44
- * (ctrl+left/ctrl+right move the focus ring, enter selects). The activator
45
- * is bare Enter, not ctrl+enter: terminals send the same byte (`\r`) for
46
- * both, so a ctrl-modified Enter can never reach the engine. Insert mode
47
- * stays mouse-only because these bindings are mode-restricted and never
48
- * fire there.
49
- */
50
- export function ToolBar({ mode = null, tools, session = null, onToggleFileTree, onOpenSettings, fileTreeOpen = false, }) {
51
- const { rows, columns } = useWindowSize();
52
- const { t } = useI18n();
53
- const { boundKeyboard } = useKeyboard();
54
- const [activeIndex, setActiveIndex] = useState(0);
55
- // The default tools need live context, so they are built per instance
56
- // instead of at module level; `tools` stays the extension point.
57
- const defaultTools = useMemo(() => [
58
- {
59
- id: "save",
60
- labelKey: "toolbar.save",
61
- onClick: () => {
62
- session?.save();
63
- },
64
- },
65
- {
66
- id: "file-tree",
67
- labelKey: "toolbar.fileTree",
68
- onClick: onToggleFileTree ?? (() => { }),
69
- },
70
- {
71
- id: "settings",
72
- labelKey: "toolbar.settings",
73
- onClick: onOpenSettings ?? (() => { }),
74
- },
75
- ], [session, onToggleFileTree, onOpenSettings]);
76
- const activeTools = tools ?? defaultTools;
77
- // null = bar sits at the bottom-center via flex layout; once the user
78
- // grabs it, absolute positioning takes over so it follows the cursor.
79
- // Position lives in a module store so erase/re-apply remounts (prop
80
- // updates) do not reset a dragged bar back to the bottom.
81
- const pos = useBarPosition();
82
- // Live pane width, so the drag clamp follows the tree's adaptive size.
83
- const treeWidth = useTreeWidth();
84
- const [dragging, setDragging] = useState(false);
85
- const barRef = useRef(null);
86
- // Grab offset (where inside the bar the cursor went down) plus the bar's
87
- // size at grab time, kept in a ref so drag moves need no re-measurement.
88
- const grabRef = useRef({ dx: 0, dy: 0, width: 0, height: 0 });
89
- const startDrag = useCallback((event) => {
90
- const node = barRef.current;
91
- if (!node) {
92
- return;
93
- }
94
- // Measure the bar itself, not the hit region — a button may be the
95
- // captured target, and its rect would put the bar's corner at the
96
- // button's corner.
97
- const m = measureElement(node);
98
- grabRef.current = {
99
- dx: event.x - m.x - 1,
100
- dy: event.y - m.y - 1,
101
- width: m.width,
102
- height: m.height,
103
- };
104
- // Switch from flex centering to absolute positioning at the current
105
- // rect — seamless, no jump on the first frame.
106
- setBarPosition({ top: m.y, left: m.x });
107
- setDragging(true);
108
- }, []);
109
- const moveDrag = useCallback((event) => {
110
- const { dx, dy, width, height } = grabRef.current;
111
- setBarPosition(clampBarPos(event.x - dx - 1, event.y - dy - 1, width, height, columns, rows, fileTreeOpen, treeWidth));
112
- }, [columns, rows, fileTreeOpen, treeWidth]);
113
- const endDrag = useCallback(() => setDragging(false), []);
114
- // The drag clamp only runs while dragging — a terminal resize can shrink
115
- // the viewport under an absolutely-positioned bar afterwards (and opening
116
- // the file tree can shrink the free area). Re-clamp whenever any of the
117
- // layout inputs change so the bar never extends past its allowed region.
118
- useEffect(() => {
119
- const node = barRef.current;
120
- if (!node) {
121
- return;
122
- }
123
- // Read the latest position imperatively — no `pos` dependency, so this
124
- // effect only runs when a layout input actually changes.
125
- const m = measureElement(node);
126
- const current = getBarPosition();
127
- if (!current) {
128
- return;
129
- }
130
- const next = clampBarPos(current.left, current.top, m.width, m.height, columns, rows, fileTreeOpen, treeWidth);
131
- if (next.left !== current.left || next.top !== current.top) {
132
- setBarPosition(next);
133
- }
134
- }, [columns, rows, fileTreeOpen, treeWidth]);
135
- const showFocus = mode === "normal";
136
- useEffect(() => {
137
- const unbinds = [
138
- boundKeyboard(["ctrl+left"], () => setActiveIndex((i) => (i - 1 + activeTools.length) % activeTools.length), { mode: "normal" }),
139
- boundKeyboard(["ctrl+right"], () => setActiveIndex((i) => (i + 1) % activeTools.length), { mode: "normal" }),
140
- boundKeyboard(["return"], () => activeTools[activeIndex]?.onClick(), {
141
- mode: "normal",
142
- }),
143
- ];
144
- return () => unbinds.forEach((fn) => fn());
145
- }, [boundKeyboard, activeTools, activeIndex]);
146
- return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "flex-end" },
147
- React.createElement(Box, { ref: barRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, height: 3, flexDirection: "row", justifyContent: "center", alignItems: "center" }, activeTools.map((tool, index) => (React.createElement(ToolButton, { key: tool.id, text: t(tool.labelKey), priority: 1, focused: showFocus && index === activeIndex, onClick: tool.onClick, dragging: dragging, onDragStart: startDrag, onDragMove: moveDrag, onDragEnd: endDrag }))))));
148
- }
@@ -1,6 +0,0 @@
1
- /** Imperative read for drag handlers. */
2
- export declare function getTreeWidth(): number;
3
- /** Update the reported pane width (no-op when unchanged). */
4
- export declare function setTreeWidth(next: number): void;
5
- /** Reactive binding: re-renders whenever the pane width changes. */
6
- export declare function useTreeWidth(): number;
File without changes
File without changes
File without changes