blots-editor 0.0.12 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/asset/language/en.json +16 -1
- package/asset/language/zh.json +16 -1
- package/dist/core/document/document.d.ts +14 -0
- package/dist/core/document/document.js +24 -2
- package/dist/core/document/text-line.d.ts +16 -1
- package/dist/core/document/text-line.js +36 -3
- package/dist/core/file-tree-model.d.ts +40 -0
- package/dist/core/file-tree-model.js +118 -0
- package/dist/core/session.d.ts +58 -0
- package/dist/core/session.js +116 -0
- package/dist/core/settings/schema.d.ts +13 -0
- package/dist/core/settings/schema.js +11 -0
- package/dist/core/settings/useSettings.d.ts +2 -0
- package/dist/core/settings/useSettings.js +17 -12
- package/dist/view/command-bar.d.ts +6 -1
- package/dist/view/command-bar.js +11 -2
- package/dist/view/editor-setting.d.ts +8 -0
- package/dist/view/editor-setting.js +69 -0
- package/dist/view/editor.js +122 -10
- package/dist/view/file-tree.d.ts +15 -0
- package/dist/view/file-tree.js +217 -0
- package/dist/view/information-bar.d.ts +8 -2
- package/dist/view/information-bar.js +26 -4
- package/dist/view/modal-frame.js +36 -4
- package/dist/view/settings.d.ts +11 -0
- package/dist/view/settings.js +114 -7
- package/dist/view/tool-bar.d.ts +62 -0
- package/dist/view/tool-bar.js +148 -0
- package/dist/view/toolbar-store.d.ts +19 -0
- package/dist/view/toolbar-store.js +25 -0
- package/dist/view/tree-width-store.d.ts +6 -0
- package/dist/view/tree-width-store.js +31 -0
- package/package.json +1 -1
package/asset/language/en.json
CHANGED
|
@@ -14,5 +14,20 @@
|
|
|
14
14
|
"editor.mode.insert": "INSERT",
|
|
15
15
|
"editor.mode.normal": "NORMAL",
|
|
16
16
|
"command.prompt": "Command",
|
|
17
|
-
"command.unknown": "Unknown command: {cmd}"
|
|
17
|
+
"command.unknown": "Unknown command: {cmd}",
|
|
18
|
+
"command.saveError": "Save failed",
|
|
19
|
+
"toolbar.fileTree": "File Tree",
|
|
20
|
+
"toolbar.save": "Save",
|
|
21
|
+
"toolbar.settings": "Settings",
|
|
22
|
+
"editor.setting.title": "Menu",
|
|
23
|
+
"editor.setting.openSetting": "Settings",
|
|
24
|
+
"editor.setting.exit": "Exit",
|
|
25
|
+
"confirm.save": "Save",
|
|
26
|
+
"confirm.discard": "Discard",
|
|
27
|
+
"confirm.cancel": "Cancel",
|
|
28
|
+
"settings.fileTreeRoot": "FileTree Root",
|
|
29
|
+
"settings.fileTreeRoot.startup": "Startup directory",
|
|
30
|
+
"settings.fileTreeRoot.custom": "Custom path",
|
|
31
|
+
"settings.ok": "OK",
|
|
32
|
+
"settings.cancel": "Cancel"
|
|
18
33
|
}
|
package/asset/language/zh.json
CHANGED
|
@@ -14,5 +14,20 @@
|
|
|
14
14
|
"editor.mode.insert": "插入",
|
|
15
15
|
"editor.mode.normal": "普通",
|
|
16
16
|
"command.prompt": "命令",
|
|
17
|
-
"command.unknown": "未知命令:{cmd}"
|
|
17
|
+
"command.unknown": "未知命令:{cmd}",
|
|
18
|
+
"command.saveError": "保存失败",
|
|
19
|
+
"toolbar.fileTree": "文件树",
|
|
20
|
+
"toolbar.save": "保存",
|
|
21
|
+
"toolbar.settings": "设置",
|
|
22
|
+
"editor.setting.title": "菜单",
|
|
23
|
+
"editor.setting.openSetting": "设置",
|
|
24
|
+
"editor.setting.exit": "退出",
|
|
25
|
+
"confirm.save": "保存",
|
|
26
|
+
"confirm.discard": "丢弃",
|
|
27
|
+
"confirm.cancel": "取消",
|
|
28
|
+
"settings.fileTreeRoot": "文件树根目录",
|
|
29
|
+
"settings.fileTreeRoot.startup": "启动目录",
|
|
30
|
+
"settings.fileTreeRoot.custom": "自定义路径",
|
|
31
|
+
"settings.ok": "确定",
|
|
32
|
+
"settings.cancel": "取消"
|
|
18
33
|
}
|
|
@@ -26,6 +26,19 @@ export declare class Document {
|
|
|
26
26
|
/** Snapshot of all line texts (for rendering; internally the model is `TextLine[]`). */
|
|
27
27
|
get lines(): string[];
|
|
28
28
|
getLine(i: number): string;
|
|
29
|
+
/**
|
|
30
|
+
* The full document text with normalized LF line endings — the inverse
|
|
31
|
+
* of the constructor's `split(/\r?\n/)`, so text → document → text is
|
|
32
|
+
* lossless. Used for saving and dirty-hash comparison.
|
|
33
|
+
*/
|
|
34
|
+
get text(): string;
|
|
35
|
+
/**
|
|
36
|
+
* Replace the whole content (used when opening a file). Resets the
|
|
37
|
+
* cursor and scroll state; the wrap width is view-configured and
|
|
38
|
+
* untouched. The `Document` instance stays stable, so controllers and
|
|
39
|
+
* bindings that hold it keep working.
|
|
40
|
+
*/
|
|
41
|
+
setText(text: string): void;
|
|
29
42
|
get cursor(): CursorState;
|
|
30
43
|
get scrollTop(): number;
|
|
31
44
|
/** Soft-wrap width in terminal cells; `Infinity` disables wrapping. */
|
|
@@ -46,6 +59,7 @@ export declare class Document {
|
|
|
46
59
|
start: number;
|
|
47
60
|
end: number;
|
|
48
61
|
first: boolean;
|
|
62
|
+
text: string;
|
|
49
63
|
} | null;
|
|
50
64
|
/** Global visual line the cursor sits on. */
|
|
51
65
|
get cursorVisualLine(): number;
|
|
@@ -36,6 +36,27 @@ export class Document {
|
|
|
36
36
|
getLine(i) {
|
|
37
37
|
return this._lines[i].text;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The full document text with normalized LF line endings — the inverse
|
|
41
|
+
* of the constructor's `split(/\r?\n/)`, so text → document → text is
|
|
42
|
+
* lossless. Used for saving and dirty-hash comparison.
|
|
43
|
+
*/
|
|
44
|
+
get text() {
|
|
45
|
+
return this._lines.map((l) => l.text).join("\n");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Replace the whole content (used when opening a file). Resets the
|
|
49
|
+
* cursor and scroll state; the wrap width is view-configured and
|
|
50
|
+
* untouched. The `Document` instance stays stable, so controllers and
|
|
51
|
+
* bindings that hold it keep working.
|
|
52
|
+
*/
|
|
53
|
+
setText(text) {
|
|
54
|
+
this._lines = text.split(/\r?\n/).map((t) => new TextLine(t));
|
|
55
|
+
this._cursor = { line: 0, logical: 0 };
|
|
56
|
+
this._scrollTop = 0;
|
|
57
|
+
this._viewScrollTop = null;
|
|
58
|
+
this._scrollRemainder = 0;
|
|
59
|
+
}
|
|
39
60
|
get cursor() {
|
|
40
61
|
const line = this._lines[this._cursor.line];
|
|
41
62
|
return {
|
|
@@ -59,14 +80,14 @@ export class Document {
|
|
|
59
80
|
lineSegments(line) {
|
|
60
81
|
const tl = this._lines[line];
|
|
61
82
|
if (tl.text.length === 0) {
|
|
62
|
-
return [{ start: 0, end: 0 }];
|
|
83
|
+
return [{ start: 0, end: 0, text: "" }];
|
|
63
84
|
}
|
|
64
85
|
const segs = [];
|
|
65
86
|
let segVisual = 0;
|
|
66
87
|
let segStart = 0;
|
|
67
88
|
while (segStart < tl.text.length) {
|
|
68
89
|
const seg = tl.segmentFrom(segVisual, this._wrapWidth);
|
|
69
|
-
segs.push({ start: segStart, end: seg.endLogical });
|
|
90
|
+
segs.push({ start: segStart, end: seg.endLogical, text: seg.text });
|
|
70
91
|
if (seg.text === "") {
|
|
71
92
|
break;
|
|
72
93
|
}
|
|
@@ -101,6 +122,7 @@ export class Document {
|
|
|
101
122
|
start: segs[remaining].start,
|
|
102
123
|
end: segs[remaining].end,
|
|
103
124
|
first: remaining === 0,
|
|
125
|
+
text: segs[remaining].text,
|
|
104
126
|
};
|
|
105
127
|
}
|
|
106
128
|
remaining -= segs.length;
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab stop used for display width and tab expansion. Tabs are expanded by
|
|
3
|
+
* the editor (see {@link expandTabs}) so the rendered text is plain spaces —
|
|
4
|
+
* this keeps Ink's internal layout, the terminal paint, and the width math
|
|
5
|
+
* identical, which raw tabs would break (string-width counts a tab as one
|
|
6
|
+
* cell while terminals jump to the next tab stop). 4 matches the common
|
|
7
|
+
* editor default.
|
|
8
|
+
*/
|
|
9
|
+
export declare const TAB_STOP = 4;
|
|
10
|
+
/**
|
|
11
|
+
* Expand tabs to spaces aligned to `startColumn`'s tab stops. This is what
|
|
12
|
+
* gets rendered (and what segment widths are computed against), so raw tabs
|
|
13
|
+
* never reach the terminal.
|
|
14
|
+
*/
|
|
15
|
+
export declare function expandTabs(text: string, startColumn?: number): string;
|
|
1
16
|
/**
|
|
2
17
|
* A single line of text with its terminal width cached.
|
|
3
18
|
*
|
|
@@ -6,7 +21,7 @@
|
|
|
6
21
|
* the same prefix at the middle index — the cursor never sits inside a
|
|
7
22
|
* surrogate pair, but aligning the array to code units lets `visualAt` /
|
|
8
23
|
* `logicalAt` use direct indexing and binary search instead of per-character
|
|
9
|
-
* scans on every call.
|
|
24
|
+
* scans on every call. Tabs are column-relative (width to the next stop).
|
|
10
25
|
*/
|
|
11
26
|
export declare class TextLine {
|
|
12
27
|
readonly text: string;
|
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
import stringWidth from "string-width";
|
|
2
|
+
/**
|
|
3
|
+
* Tab stop used for display width and tab expansion. Tabs are expanded by
|
|
4
|
+
* the editor (see {@link expandTabs}) so the rendered text is plain spaces —
|
|
5
|
+
* this keeps Ink's internal layout, the terminal paint, and the width math
|
|
6
|
+
* identical, which raw tabs would break (string-width counts a tab as one
|
|
7
|
+
* cell while terminals jump to the next tab stop). 4 matches the common
|
|
8
|
+
* editor default.
|
|
9
|
+
*/
|
|
10
|
+
export const TAB_STOP = 4;
|
|
11
|
+
/** Width of one character at the given column; a tab jumps to the next stop. */
|
|
12
|
+
function charWidth(ch, col) {
|
|
13
|
+
if (ch === "\t") {
|
|
14
|
+
return TAB_STOP - (col % TAB_STOP);
|
|
15
|
+
}
|
|
16
|
+
return stringWidth(ch);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Expand tabs to spaces aligned to `startColumn`'s tab stops. This is what
|
|
20
|
+
* gets rendered (and what segment widths are computed against), so raw tabs
|
|
21
|
+
* never reach the terminal.
|
|
22
|
+
*/
|
|
23
|
+
export function expandTabs(text, startColumn = 0) {
|
|
24
|
+
let col = startColumn;
|
|
25
|
+
let out = "";
|
|
26
|
+
for (const ch of text) {
|
|
27
|
+
const w = charWidth(ch, col);
|
|
28
|
+
out += ch === "\t" ? " ".repeat(w) : ch;
|
|
29
|
+
col += w;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
2
33
|
/**
|
|
3
34
|
* A single line of text with its terminal width cached.
|
|
4
35
|
*
|
|
@@ -7,7 +38,7 @@ import stringWidth from "string-width";
|
|
|
7
38
|
* the same prefix at the middle index — the cursor never sits inside a
|
|
8
39
|
* surrogate pair, but aligning the array to code units lets `visualAt` /
|
|
9
40
|
* `logicalAt` use direct indexing and binary search instead of per-character
|
|
10
|
-
* scans on every call.
|
|
41
|
+
* scans on every call. Tabs are column-relative (width to the next stop).
|
|
11
42
|
*/
|
|
12
43
|
export class TextLine {
|
|
13
44
|
text;
|
|
@@ -19,7 +50,8 @@ export class TextLine {
|
|
|
19
50
|
let sum = 0;
|
|
20
51
|
for (let i = 0; i < text.length;) {
|
|
21
52
|
const cp = text.codePointAt(i);
|
|
22
|
-
const
|
|
53
|
+
const ch = String.fromCodePoint(cp);
|
|
54
|
+
const w = charWidth(ch, sum);
|
|
23
55
|
const units = cp > 0xffff ? 2 : 1;
|
|
24
56
|
for (let k = 0; k < units; k++) {
|
|
25
57
|
prefix.push(sum + w);
|
|
@@ -83,7 +115,8 @@ export class TextLine {
|
|
|
83
115
|
end = start + (this.text.codePointAt(start) > 0xffff ? 2 : 1);
|
|
84
116
|
}
|
|
85
117
|
return {
|
|
86
|
-
|
|
118
|
+
// Tabs expanded so the caller can render the segment verbatim.
|
|
119
|
+
text: expandTabs(this.text.slice(start, end), startVisualActual),
|
|
87
120
|
endVisual: this.visualAt(end),
|
|
88
121
|
endLogical: end,
|
|
89
122
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FileTreeSettings } from "./settings/schema.js";
|
|
2
|
+
/** Clear the scan cache (refresh button, tests). */
|
|
3
|
+
export declare function clearFileTreeCache(): void;
|
|
4
|
+
/** Scan a directory, serving repeat calls for the same root from the cache. */
|
|
5
|
+
export declare function scanDirectoryCached(root: string): FileNode | null;
|
|
6
|
+
/** One entry of the scanned tree. Directories carry their children. */
|
|
7
|
+
export type FileNode = {
|
|
8
|
+
/** Entry name (not the full path). */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Absolute path. */
|
|
11
|
+
path: string;
|
|
12
|
+
isDir: boolean;
|
|
13
|
+
/** Children of a directory; undefined for files. */
|
|
14
|
+
children?: FileNode[];
|
|
15
|
+
};
|
|
16
|
+
/** Expand a leading `~` to the user's home directory. */
|
|
17
|
+
export declare function expandHome(path: string): string;
|
|
18
|
+
/** Resolve the file-tree root from settings: startup dir or custom path. */
|
|
19
|
+
export declare function resolveFileTreeRoot(settings: FileTreeSettings, startupDir: string): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Recursively scan a directory into a tree. Symlinks are never followed
|
|
22
|
+
* (they can loop), dotfiles and known dependency/VCS dirs are skipped.
|
|
23
|
+
* Directories sort before files, both alphabetically.
|
|
24
|
+
*
|
|
25
|
+
* @returns The root node, or null when the directory cannot be read.
|
|
26
|
+
*/
|
|
27
|
+
export declare function scanDirectory(root: string): FileNode | null;
|
|
28
|
+
/** One visible tree row produced by flattening with expansion state. */
|
|
29
|
+
export type TreeRow = {
|
|
30
|
+
node: FileNode;
|
|
31
|
+
/** Indentation depth (root = 0). */
|
|
32
|
+
depth: number;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Flatten the tree into the rows to render: children of expanded directories
|
|
36
|
+
* appear under their parent, collapsed subtrees are omitted.
|
|
37
|
+
*/
|
|
38
|
+
export declare function flattenTree(root: FileNode, expanded: ReadonlySet<string>): TreeRow[];
|
|
39
|
+
/** Expand a directory, or collapse it when already expanded. */
|
|
40
|
+
export declare function toggleExpanded(expanded: Set<string>, path: string): void;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Scan cache: large directories take a while to recurse, and the file tree
|
|
6
|
+
* is re-scanned on every open. Once scanned, a root is served from the cache
|
|
7
|
+
* forever — refreshing the tree is an explicit action (`clearFileTreeCache`
|
|
8
|
+
* via the tree's refresh button), not a time-based expiry.
|
|
9
|
+
*/
|
|
10
|
+
const scanCache = new Map();
|
|
11
|
+
/** Clear the scan cache (refresh button, tests). */
|
|
12
|
+
export function clearFileTreeCache() {
|
|
13
|
+
scanCache.clear();
|
|
14
|
+
}
|
|
15
|
+
/** Scan a directory, serving repeat calls for the same root from the cache. */
|
|
16
|
+
export function scanDirectoryCached(root) {
|
|
17
|
+
const hit = scanCache.get(root);
|
|
18
|
+
if (hit) {
|
|
19
|
+
return hit;
|
|
20
|
+
}
|
|
21
|
+
const tree = scanDirectory(root);
|
|
22
|
+
if (tree) {
|
|
23
|
+
scanCache.set(root, tree);
|
|
24
|
+
}
|
|
25
|
+
return tree;
|
|
26
|
+
}
|
|
27
|
+
/** Directories skipped at every depth (dependency dirs, VCS internals). */
|
|
28
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".next", ".cache"]);
|
|
29
|
+
/** Any entry starting with "." is hidden (dotfiles) and skipped. */
|
|
30
|
+
const isHidden = (name) => name.startsWith(".");
|
|
31
|
+
/** Expand a leading `~` to the user's home directory. */
|
|
32
|
+
export function expandHome(path) {
|
|
33
|
+
return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
34
|
+
}
|
|
35
|
+
/** Resolve the file-tree root from settings: startup dir or custom path. */
|
|
36
|
+
export function resolveFileTreeRoot(settings, startupDir) {
|
|
37
|
+
if (settings.root === "custom") {
|
|
38
|
+
const custom = expandHome(settings.customPath.trim());
|
|
39
|
+
return custom.length > 0 ? custom : null;
|
|
40
|
+
}
|
|
41
|
+
return startupDir;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Recursively scan a directory into a tree. Symlinks are never followed
|
|
45
|
+
* (they can loop), dotfiles and known dependency/VCS dirs are skipped.
|
|
46
|
+
* Directories sort before files, both alphabetically.
|
|
47
|
+
*
|
|
48
|
+
* @returns The root node, or null when the directory cannot be read.
|
|
49
|
+
*/
|
|
50
|
+
export function scanDirectory(root) {
|
|
51
|
+
const entries = readDirSafe(root);
|
|
52
|
+
if (!entries) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const nodes = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const name = entry.name;
|
|
58
|
+
if (isHidden(name) || entry.isSymbolicLink()) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const path = join(root, name);
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
if (SKIP_DIRS.has(name)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
nodes.push({ name, path, isDir: true, children: scanChildren(path) });
|
|
67
|
+
}
|
|
68
|
+
else if (entry.isFile()) {
|
|
69
|
+
nodes.push({ name, path, isDir: false });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Code-unit compare, not localeCompare: deterministic across locales
|
|
73
|
+
// (localeCompare reorders by the host's locale).
|
|
74
|
+
nodes.sort((a, b) => a.isDir === b.isDir ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.isDir ? -1 : 1);
|
|
75
|
+
return { name: root, path: root, isDir: true, children: nodes };
|
|
76
|
+
}
|
|
77
|
+
function scanChildren(dir) {
|
|
78
|
+
const child = scanDirectory(dir);
|
|
79
|
+
return child?.children;
|
|
80
|
+
}
|
|
81
|
+
/** Read a directory without throwing; null when unreadable (EACCES etc.). */
|
|
82
|
+
function readDirSafe(path) {
|
|
83
|
+
try {
|
|
84
|
+
return readdirSync(path, { withFileTypes: true });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Flatten the tree into the rows to render: children of expanded directories
|
|
92
|
+
* appear under their parent, collapsed subtrees are omitted.
|
|
93
|
+
*/
|
|
94
|
+
export function flattenTree(root, expanded) {
|
|
95
|
+
const rows = [];
|
|
96
|
+
const walk = (node, depth) => {
|
|
97
|
+
rows.push({ node, depth });
|
|
98
|
+
if (node.isDir && node.children && expanded.has(node.path)) {
|
|
99
|
+
for (const child of node.children) {
|
|
100
|
+
walk(child, depth + 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
// The root itself is never rendered (it is the pane's title area).
|
|
105
|
+
for (const child of root.children ?? []) {
|
|
106
|
+
walk(child, 1);
|
|
107
|
+
}
|
|
108
|
+
return rows;
|
|
109
|
+
}
|
|
110
|
+
/** Expand a directory, or collapse it when already expanded. */
|
|
111
|
+
export function toggleExpanded(expanded, path) {
|
|
112
|
+
if (expanded.has(path)) {
|
|
113
|
+
expanded.delete(path);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
expanded.add(path);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { EditorController, type EditorOptions } from "./editor-controller.js";
|
|
2
|
+
export type ActionResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error: string;
|
|
7
|
+
};
|
|
8
|
+
/** Transient status message shown in the information bar. */
|
|
9
|
+
export type SessionMessage = {
|
|
10
|
+
/** Display text (already localized where applicable). */
|
|
11
|
+
text: string;
|
|
12
|
+
kind: "success" | "error";
|
|
13
|
+
};
|
|
14
|
+
type SessionListener = () => void;
|
|
15
|
+
/**
|
|
16
|
+
* File-session coordinator shared by the editor, file tree, and toolbar.
|
|
17
|
+
*
|
|
18
|
+
* Owns the single open document (via an {@link EditorController}), the path
|
|
19
|
+
* it was loaded from, and the saved-content hash used for the dirty check.
|
|
20
|
+
* All file I/O happens here; components only call `open` / `save` and react
|
|
21
|
+
* to `onChange`. The dirty check compares the current document text against
|
|
22
|
+
* the hash of the last saved content (node `crypto` sha256), so a stray
|
|
23
|
+
* formatting change never needs manual dirty bookkeeping.
|
|
24
|
+
*
|
|
25
|
+
* An instance is created per editor mount (see `Editor`) and passed to the
|
|
26
|
+
* layer elements — the file tree, toolbar, and command bar all receive it
|
|
27
|
+
* via props.
|
|
28
|
+
*/
|
|
29
|
+
export declare class EditorSession {
|
|
30
|
+
private readonly _controller;
|
|
31
|
+
private _filePath;
|
|
32
|
+
/** Hash of the content last written to `_filePath`; null while untitled. */
|
|
33
|
+
private _savedHash;
|
|
34
|
+
private _message;
|
|
35
|
+
private readonly _listeners;
|
|
36
|
+
constructor(initialText?: string, options?: EditorOptions);
|
|
37
|
+
get controller(): EditorController;
|
|
38
|
+
/** Absolute path of the open file, or null for the untitled buffer. */
|
|
39
|
+
get filePath(): string | null;
|
|
40
|
+
/** File name for display; `[untitled]` when no file is open. */
|
|
41
|
+
get displayName(): string;
|
|
42
|
+
/** Last operation message (save result, open error), or null. */
|
|
43
|
+
get message(): SessionMessage | null;
|
|
44
|
+
/** True when the buffer differs from the saved content of its file. */
|
|
45
|
+
isDirty(): boolean;
|
|
46
|
+
onChange(listener: SessionListener): () => void;
|
|
47
|
+
/**
|
|
48
|
+
* Load a file into the buffer, replacing the previous document. The
|
|
49
|
+
* buffer becomes clean (hash of the just-read content).
|
|
50
|
+
*/
|
|
51
|
+
open(filePath: string): ActionResult;
|
|
52
|
+
/** Write the buffer to the open file, clearing the dirty state. */
|
|
53
|
+
save(): ActionResult;
|
|
54
|
+
private hash;
|
|
55
|
+
private fail;
|
|
56
|
+
private _notify;
|
|
57
|
+
}
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { EditorController } from "./editor-controller.js";
|
|
5
|
+
/**
|
|
6
|
+
* File-session coordinator shared by the editor, file tree, and toolbar.
|
|
7
|
+
*
|
|
8
|
+
* Owns the single open document (via an {@link EditorController}), the path
|
|
9
|
+
* it was loaded from, and the saved-content hash used for the dirty check.
|
|
10
|
+
* All file I/O happens here; components only call `open` / `save` and react
|
|
11
|
+
* to `onChange`. The dirty check compares the current document text against
|
|
12
|
+
* the hash of the last saved content (node `crypto` sha256), so a stray
|
|
13
|
+
* formatting change never needs manual dirty bookkeeping.
|
|
14
|
+
*
|
|
15
|
+
* An instance is created per editor mount (see `Editor`) and passed to the
|
|
16
|
+
* layer elements — the file tree, toolbar, and command bar all receive it
|
|
17
|
+
* via props.
|
|
18
|
+
*/
|
|
19
|
+
export class EditorSession {
|
|
20
|
+
_controller;
|
|
21
|
+
_filePath = null;
|
|
22
|
+
/** Hash of the content last written to `_filePath`; null while untitled. */
|
|
23
|
+
_savedHash = null;
|
|
24
|
+
_message = null;
|
|
25
|
+
_listeners = new Set();
|
|
26
|
+
constructor(initialText = "", options = {}) {
|
|
27
|
+
this._controller = new EditorController(initialText, options);
|
|
28
|
+
}
|
|
29
|
+
get controller() {
|
|
30
|
+
return this._controller;
|
|
31
|
+
}
|
|
32
|
+
/** Absolute path of the open file, or null for the untitled buffer. */
|
|
33
|
+
get filePath() {
|
|
34
|
+
return this._filePath;
|
|
35
|
+
}
|
|
36
|
+
/** File name for display; `[untitled]` when no file is open. */
|
|
37
|
+
get displayName() {
|
|
38
|
+
return this._filePath ? basename(this._filePath) : "[untitled]";
|
|
39
|
+
}
|
|
40
|
+
/** Last operation message (save result, open error), or null. */
|
|
41
|
+
get message() {
|
|
42
|
+
return this._message;
|
|
43
|
+
}
|
|
44
|
+
/** True when the buffer differs from the saved content of its file. */
|
|
45
|
+
isDirty() {
|
|
46
|
+
if (this._filePath === null || this._savedHash === null) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return this.hash(this._controller.document.text) !== this._savedHash;
|
|
50
|
+
}
|
|
51
|
+
onChange(listener) {
|
|
52
|
+
this._listeners.add(listener);
|
|
53
|
+
return () => {
|
|
54
|
+
this._listeners.delete(listener);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Load a file into the buffer, replacing the previous document. The
|
|
59
|
+
* buffer becomes clean (hash of the just-read content).
|
|
60
|
+
*/
|
|
61
|
+
open(filePath) {
|
|
62
|
+
let text;
|
|
63
|
+
try {
|
|
64
|
+
text = readFileSync(filePath, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return this.fail(err);
|
|
68
|
+
}
|
|
69
|
+
// NUL bytes mark binary content — refuse it instead of rendering garbage.
|
|
70
|
+
if (text.includes("\0")) {
|
|
71
|
+
this._message = { text: "Binary file", kind: "error" };
|
|
72
|
+
this._notify();
|
|
73
|
+
return { ok: false, error: "Binary file" };
|
|
74
|
+
}
|
|
75
|
+
this._controller.document.setText(text);
|
|
76
|
+
this._filePath = filePath;
|
|
77
|
+
// Hash the NORMALIZED document text, not the raw disk bytes: the
|
|
78
|
+
// document normalizes CRLF to LF, so hashing the raw text would
|
|
79
|
+
// flag every CRLF file as dirty on open.
|
|
80
|
+
this._savedHash = this.hash(this._controller.document.text);
|
|
81
|
+
this._message = null;
|
|
82
|
+
this._notify();
|
|
83
|
+
return { ok: true };
|
|
84
|
+
}
|
|
85
|
+
/** Write the buffer to the open file, clearing the dirty state. */
|
|
86
|
+
save() {
|
|
87
|
+
if (this._filePath === null) {
|
|
88
|
+
this._message = { text: "No file open", kind: "error" };
|
|
89
|
+
this._notify();
|
|
90
|
+
return { ok: false, error: "No file open" };
|
|
91
|
+
}
|
|
92
|
+
const text = this._controller.document.text;
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(this._filePath, text, "utf8");
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
return this.fail(err);
|
|
98
|
+
}
|
|
99
|
+
this._savedHash = this.hash(text);
|
|
100
|
+
this._message = { text: "Saved", kind: "success" };
|
|
101
|
+
this._notify();
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
hash(text) {
|
|
105
|
+
return createHash("sha256").update(text).digest("hex");
|
|
106
|
+
}
|
|
107
|
+
fail(err) {
|
|
108
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
109
|
+
this._message = { text: error, kind: "error" };
|
|
110
|
+
this._notify();
|
|
111
|
+
return { ok: false, error };
|
|
112
|
+
}
|
|
113
|
+
_notify() {
|
|
114
|
+
this._listeners.forEach((fn) => fn());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -9,15 +9,28 @@ import { z } from "zod";
|
|
|
9
9
|
*/
|
|
10
10
|
/** Wheel sensitivity in visual lines per notch, 1.0–10.0, 0.5 steps. */
|
|
11
11
|
export declare const sensitivitySchema: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
/** File-tree root source: the process startup directory or a custom path. */
|
|
13
|
+
export declare const fileTreeRootSchema: z.ZodDefault<z.ZodEnum<{
|
|
14
|
+
startup: "startup";
|
|
15
|
+
custom: "custom";
|
|
16
|
+
}>>;
|
|
12
17
|
export declare const settingsSchema: z.ZodObject<{
|
|
13
18
|
language: z.ZodDefault<z.ZodString>;
|
|
14
19
|
wheel: z.ZodDefault<z.ZodObject<{
|
|
15
20
|
cursor: z.ZodDefault<z.ZodNumber>;
|
|
16
21
|
view: z.ZodDefault<z.ZodNumber>;
|
|
17
22
|
}, z.core.$strip>>;
|
|
23
|
+
fileTree: z.ZodDefault<z.ZodObject<{
|
|
24
|
+
root: z.ZodDefault<z.ZodEnum<{
|
|
25
|
+
startup: "startup";
|
|
26
|
+
custom: "custom";
|
|
27
|
+
}>>;
|
|
28
|
+
customPath: z.ZodDefault<z.ZodString>;
|
|
29
|
+
}, z.core.$strip>>;
|
|
18
30
|
}, z.core.$strip>;
|
|
19
31
|
export type EditorSettings = z.infer<typeof settingsSchema>;
|
|
20
32
|
export type WheelSensitivity = EditorSettings["wheel"];
|
|
33
|
+
export type FileTreeSettings = EditorSettings["fileTree"];
|
|
21
34
|
export declare const DEFAULT_SETTINGS: EditorSettings;
|
|
22
35
|
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
23
36
|
export declare function parseSettings(raw: unknown): EditorSettings;
|
|
@@ -15,6 +15,8 @@ export const sensitivitySchema = z
|
|
|
15
15
|
.step(0.5)
|
|
16
16
|
.default(1);
|
|
17
17
|
const DEFAULT_WHEEL = { cursor: 1, view: 3 };
|
|
18
|
+
/** File-tree root source: the process startup directory or a custom path. */
|
|
19
|
+
export const fileTreeRootSchema = z.enum(["startup", "custom"]).default("startup");
|
|
18
20
|
export const settingsSchema = z.object({
|
|
19
21
|
/** Persisted UI language (validated at the app layer; unknown codes fall back). */
|
|
20
22
|
language: z.string().min(1).default("en"),
|
|
@@ -26,10 +28,19 @@ export const settingsSchema = z.object({
|
|
|
26
28
|
view: sensitivitySchema,
|
|
27
29
|
})
|
|
28
30
|
.default(DEFAULT_WHEEL),
|
|
31
|
+
fileTree: z
|
|
32
|
+
.object({
|
|
33
|
+
/** Which directory the file tree scans: startup dir or `customPath`. */
|
|
34
|
+
root: fileTreeRootSchema,
|
|
35
|
+
/** Absolute path scanned when `root` is "custom"; ignored otherwise. */
|
|
36
|
+
customPath: z.string().default(""),
|
|
37
|
+
})
|
|
38
|
+
.default({ root: "startup", customPath: "" }),
|
|
29
39
|
});
|
|
30
40
|
export const DEFAULT_SETTINGS = {
|
|
31
41
|
language: "en",
|
|
32
42
|
wheel: DEFAULT_WHEEL,
|
|
43
|
+
fileTree: { root: "startup", customPath: "" },
|
|
33
44
|
};
|
|
34
45
|
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
35
46
|
export function parseSettings(raw) {
|
|
@@ -16,6 +16,8 @@ export type SettingsApi = {
|
|
|
16
16
|
commit: () => void;
|
|
17
17
|
/** Persist the UI language (called alongside the i18n `setLanguage`). */
|
|
18
18
|
setLanguage: (code: string) => void;
|
|
19
|
+
/** Persist the file-tree root settings. */
|
|
20
|
+
setFileTree: (fileTree: EditorSettings["fileTree"]) => void;
|
|
19
21
|
};
|
|
20
22
|
/**
|
|
21
23
|
* React binding for the shared {@link settingsStore}. Re-renders subscribers
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useSyncExternalStore } from "react";
|
|
1
|
+
import { useMemo, useSyncExternalStore } from "react";
|
|
2
2
|
import { SettingsStore } from "./store.js";
|
|
3
3
|
/**
|
|
4
4
|
* Shared settings store for the whole editor. One instance serves the
|
|
@@ -13,15 +13,20 @@ export const settingsStore = new SettingsStore();
|
|
|
13
13
|
*/
|
|
14
14
|
export function useSettings() {
|
|
15
15
|
const settings = useSyncExternalStore(settingsStore.subscribe, () => settingsStore.settings, () => settingsStore.settings);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
16
|
+
// Memoized per settings snapshot so the returned functions keep stable
|
|
17
|
+
// identities across renders — effect deps that reference them stay calm.
|
|
18
|
+
return useMemo(() => {
|
|
19
|
+
const next = (key, value) => ({
|
|
20
|
+
...settings,
|
|
21
|
+
wheel: { ...settings.wheel, [key]: value },
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
settings,
|
|
25
|
+
setSensitivity: (key, value) => settingsStore.persist(next(key, value)),
|
|
26
|
+
setDraft: (key, value) => settingsStore.update(next(key, value)),
|
|
27
|
+
commit: () => settingsStore.commit(),
|
|
28
|
+
setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
|
|
29
|
+
setFileTree: (fileTree) => settingsStore.persist({ ...settings, fileTree }),
|
|
30
|
+
};
|
|
31
|
+
}, [settings]);
|
|
27
32
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import type { EditorSession } from "../core/session.js";
|
|
3
|
+
export type CommandBarProps = {
|
|
4
|
+
/** File session used by `:save`. */
|
|
5
|
+
session?: EditorSession | null;
|
|
6
|
+
};
|
|
2
7
|
/**
|
|
3
8
|
* Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
|
|
4
9
|
* element, so it exclusively owns the keyboard while open — the editor's
|
|
5
10
|
* bindings (including the insert-mode `*` wildcard) stay dormant.
|
|
6
11
|
*/
|
|
7
|
-
export declare function CommandBar(): React.JSX.Element;
|
|
12
|
+
export declare function CommandBar({ session }: CommandBarProps): React.JSX.Element;
|