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.
- package/asset/language/en.json +4 -0
- package/asset/language/zh.json +4 -0
- package/dist/core/{file-tree-model.d.ts → io/file-tree-model.d.ts} +12 -3
- package/dist/core/{file-tree-model.js → io/file-tree-model.js} +23 -12
- package/dist/core/{session.d.ts → io/session.d.ts} +1 -1
- package/dist/core/{session.js → io/session.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/{view → utils/view}/click-mapping.d.ts +1 -1
- package/dist/{view → utils/view}/frame-clamp.d.ts +4 -0
- package/dist/{view → utils/view}/frame-clamp.js +8 -0
- package/dist/{i18n-resources.js → utils/view/i18n-resources.js} +1 -1
- package/dist/view/{command-bar.d.ts → editor/command-bar.d.ts} +1 -1
- package/dist/view/{editor-setting.js → editor/editor-setting.js} +2 -2
- package/dist/view/{file-tree.d.ts → editor/file-tree.d.ts} +5 -1
- package/dist/view/{file-tree.js → editor/file-tree.js} +46 -28
- package/dist/view/{information-bar.d.ts → editor/information-bar.d.ts} +1 -1
- package/dist/view/editor/tool-bar.d.ts +36 -0
- package/dist/view/editor/tool-bar.js +134 -0
- package/dist/view/{toolbar-store.d.ts → event/subscription/toolbar-store.d.ts} +5 -6
- package/dist/view/event/subscription/tree-store.d.ts +10 -0
- package/dist/view/{tree-width-store.js → event/subscription/tree-store.js} +9 -12
- package/dist/view/{editor.js → page/editor.js} +12 -12
- package/dist/view/{main-menu.js → page/main-menu.js} +1 -1
- package/dist/view/{settings.js → page/settings.js} +3 -3
- package/dist/view/{modal-frame.js → utils/modal-frame.js} +1 -1
- package/package.json +1 -1
- package/dist/view/tool-bar.d.ts +0 -62
- package/dist/view/tool-bar.js +0 -148
- package/dist/view/tree-width-store.d.ts +0 -6
- /package/dist/{view → utils/view}/click-mapping.js +0 -0
- /package/dist/{i18n-resources.d.ts → utils/view/i18n-resources.d.ts} +0 -0
- /package/dist/view/{command-bar.js → editor/command-bar.js} +0 -0
- /package/dist/view/{editor-setting.d.ts → editor/editor-setting.d.ts} +0 -0
- /package/dist/view/{information-bar.js → editor/information-bar.js} +0 -0
- /package/dist/view/{toolbar-store.js → event/subscription/toolbar-store.js} +0 -0
- /package/dist/view/{editor.d.ts → page/editor.d.ts} +0 -0
- /package/dist/view/{main-menu.d.ts → page/main-menu.d.ts} +0 -0
- /package/dist/view/{settings.d.ts → page/settings.d.ts} +0 -0
- /package/dist/view/{modal-frame.d.ts → utils/modal-frame.d.ts} +0 -0
- /package/dist/view/{sensitivity-bar.d.ts → utils/sensitivity-bar.d.ts} +0 -0
- /package/dist/view/{sensitivity-bar.js → utils/sensitivity-bar.js} +0 -0
package/asset/language/en.json
CHANGED
|
@@ -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",
|
package/asset/language/zh.json
CHANGED
|
@@ -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 "
|
|
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):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
95
|
+
return await fs.readdir(path, { withFileTypes: true });
|
|
85
96
|
}
|
|
86
97
|
catch {
|
|
87
98
|
return null;
|
|
@@ -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 "
|
|
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 });
|
|
@@ -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("
|
|
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,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 "
|
|
5
|
-
import { ModalFrame } from "
|
|
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 {
|
|
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 "
|
|
7
|
-
import { useSettings } from "
|
|
8
|
-
import { ModalFrame } from "
|
|
9
|
-
import {
|
|
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 =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
92
|
-
|
|
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 ? (
|
|
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
|
-
} }))))
|
|
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
|
|
224
|
+
title,
|
|
207
225
|
onSave: () => {
|
|
208
226
|
const saved = session.save();
|
|
209
227
|
if (saved.ok) {
|
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
return width;
|
|
16
|
+
export function getTreePos() {
|
|
17
|
+
return pos;
|
|
19
18
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
30
|
-
return useSyncExternalStore(subscribe,
|
|
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 "
|
|
5
|
-
import { useSettings } from "
|
|
6
|
-
import { clickToPosition } from "
|
|
7
|
-
import { CommandBar } from "
|
|
8
|
-
import { EditorSetting } from "
|
|
9
|
-
import { FileTree } from "
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 "
|
|
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 "
|
|
5
|
-
import { ModalFrame } from "
|
|
6
|
-
import { SensitivityBar, snapSensitivity } from "
|
|
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 "
|
|
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
package/dist/view/tool-bar.d.ts
DELETED
|
@@ -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;
|
package/dist/view/tool-bar.js
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|