blots-editor 0.0.13 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/asset/language/en.json +19 -1
  2. package/asset/language/zh.json +19 -1
  3. package/dist/core/document/document.d.ts +14 -0
  4. package/dist/core/document/document.js +24 -2
  5. package/dist/core/document/text-line.d.ts +16 -1
  6. package/dist/core/document/text-line.js +36 -3
  7. package/dist/core/io/file-tree-model.d.ts +49 -0
  8. package/dist/core/io/file-tree-model.js +129 -0
  9. package/dist/core/io/session.d.ts +58 -0
  10. package/dist/core/io/session.js +116 -0
  11. package/dist/core/settings/schema.d.ts +13 -0
  12. package/dist/core/settings/schema.js +11 -0
  13. package/dist/core/settings/useSettings.d.ts +2 -0
  14. package/dist/core/settings/useSettings.js +17 -12
  15. package/dist/index.js +4 -4
  16. package/dist/{view → utils/view}/click-mapping.d.ts +1 -1
  17. package/dist/{view → utils/view}/frame-clamp.d.ts +4 -0
  18. package/dist/{view → utils/view}/frame-clamp.js +8 -0
  19. package/dist/{i18n-resources.js → utils/view/i18n-resources.js} +1 -1
  20. package/dist/view/editor/command-bar.d.ts +12 -0
  21. package/dist/view/{command-bar.js → editor/command-bar.js} +11 -2
  22. package/dist/view/editor/editor-setting.d.ts +8 -0
  23. package/dist/view/editor/editor-setting.js +69 -0
  24. package/dist/view/editor/file-tree.d.ts +19 -0
  25. package/dist/view/editor/file-tree.js +235 -0
  26. package/dist/view/editor/information-bar.d.ts +16 -0
  27. package/dist/view/editor/information-bar.js +35 -0
  28. package/dist/view/editor/tool-bar.d.ts +36 -0
  29. package/dist/view/editor/tool-bar.js +134 -0
  30. package/dist/view/event/subscription/toolbar-store.d.ts +18 -0
  31. package/dist/view/event/subscription/toolbar-store.js +25 -0
  32. package/dist/view/event/subscription/tree-store.d.ts +10 -0
  33. package/dist/view/event/subscription/tree-store.js +28 -0
  34. package/dist/view/{editor.js → page/editor.js} +94 -18
  35. package/dist/view/{main-menu.js → page/main-menu.js} +1 -1
  36. package/dist/view/page/settings.d.ts +13 -0
  37. package/dist/view/{settings.js → page/settings.js} +117 -10
  38. package/dist/view/utils/modal-frame.js +75 -0
  39. package/package.json +1 -1
  40. package/dist/view/command-bar.d.ts +0 -7
  41. package/dist/view/information-bar.d.ts +0 -10
  42. package/dist/view/information-bar.js +0 -13
  43. package/dist/view/modal-frame.js +0 -43
  44. package/dist/view/settings.d.ts +0 -2
  45. package/dist/view/tool-bar.d.ts +0 -51
  46. package/dist/view/tool-bar.js +0 -92
  47. /package/dist/{view → utils/view}/click-mapping.js +0 -0
  48. /package/dist/{i18n-resources.d.ts → utils/view/i18n-resources.d.ts} +0 -0
  49. /package/dist/view/{editor.d.ts → page/editor.d.ts} +0 -0
  50. /package/dist/view/{main-menu.d.ts → page/main-menu.d.ts} +0 -0
  51. /package/dist/view/{modal-frame.d.ts → utils/modal-frame.d.ts} +0 -0
  52. /package/dist/view/{sensitivity-bar.d.ts → utils/sensitivity-bar.d.ts} +0 -0
  53. /package/dist/view/{sensitivity-bar.js → utils/sensitivity-bar.js} +0 -0
@@ -15,5 +15,23 @@
15
15
  "editor.mode.normal": "NORMAL",
16
16
  "command.prompt": "Command",
17
17
  "command.unknown": "Unknown command: {cmd}",
18
- "toolbar.fileTree": "File Tree"
18
+ "command.saveError": "Save failed",
19
+ "toolbar.fileTree": "File Tree",
20
+ "toolbar.save": "Save",
21
+ "toolbar.settings": "Settings",
22
+ "fileTree.scanning": "Scanning...",
23
+ "fileTree.noDir": "No directory",
24
+ "fileTree.fail": "Scan failed",
25
+ "fileTree.unsaved": "Unsaved changes",
26
+ "editor.setting.title": "Menu",
27
+ "editor.setting.openSetting": "Settings",
28
+ "editor.setting.exit": "Exit",
29
+ "confirm.save": "Save",
30
+ "confirm.discard": "Discard",
31
+ "confirm.cancel": "Cancel",
32
+ "settings.fileTreeRoot": "FileTree Root",
33
+ "settings.fileTreeRoot.startup": "Startup directory",
34
+ "settings.fileTreeRoot.custom": "Custom path",
35
+ "settings.ok": "OK",
36
+ "settings.cancel": "Cancel"
19
37
  }
@@ -15,5 +15,23 @@
15
15
  "editor.mode.normal": "普通",
16
16
  "command.prompt": "命令",
17
17
  "command.unknown": "未知命令:{cmd}",
18
- "toolbar.fileTree": "文件树"
18
+ "command.saveError": "保存失败",
19
+ "toolbar.fileTree": "文件树",
20
+ "toolbar.save": "保存",
21
+ "toolbar.settings": "设置",
22
+ "fileTree.scanning": "正在扫描...",
23
+ "fileTree.noDir": "没有目录",
24
+ "fileTree.fail": "扫描失败",
25
+ "fileTree.unsaved": "未保存的更改",
26
+ "editor.setting.title": "菜单",
27
+ "editor.setting.openSetting": "设置",
28
+ "editor.setting.exit": "退出",
29
+ "confirm.save": "保存",
30
+ "confirm.discard": "丢弃",
31
+ "confirm.cancel": "取消",
32
+ "settings.fileTreeRoot": "文件树根目录",
33
+ "settings.fileTreeRoot.startup": "启动目录",
34
+ "settings.fileTreeRoot.custom": "自定义路径",
35
+ "settings.ok": "确定",
36
+ "settings.cancel": "取消"
19
37
  }
@@ -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 w = stringWidth(String.fromCodePoint(cp));
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
- text: this.text.slice(start, end),
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,49 @@
1
+ import type { FileTreeSettings } from "../settings/schema.js";
2
+ /** Clear the scan cache (refresh button, tests). */
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
+ };
13
+ /** Scan a directory, serving repeat calls for the same root from the cache. */
14
+ export declare function scanDirectoryCached(root: string): Promise<FileTreeState>;
15
+ /** One entry of the scanned tree. Directories carry their children. */
16
+ export type FileNode = {
17
+ /** Entry name (not the full path). */
18
+ name: string;
19
+ /** Absolute path. */
20
+ path: string;
21
+ isDir: boolean;
22
+ /** Children of a directory; undefined for files. */
23
+ children?: FileNode[];
24
+ };
25
+ /** Expand a leading `~` to the user's home directory. */
26
+ export declare function expandHome(path: string): string;
27
+ /** Resolve the file-tree root from settings: startup dir or custom path. */
28
+ export declare function resolveFileTreeRoot(settings: FileTreeSettings, startupDir: string): string | null;
29
+ /**
30
+ * Recursively scan a directory into a tree. Symlinks are never followed
31
+ * (they can loop), dotfiles and known dependency/VCS dirs are skipped.
32
+ * Directories sort before files, both alphabetically.
33
+ *
34
+ * @returns The root node, or null when the directory cannot be read.
35
+ */
36
+ export declare function scanDirectory(root: string): Promise<FileNode | null>;
37
+ /** One visible tree row produced by flattening with expansion state. */
38
+ export type TreeRow = {
39
+ node: FileNode;
40
+ /** Indentation depth (root = 0). */
41
+ depth: number;
42
+ };
43
+ /**
44
+ * Flatten the tree into the rows to render: children of expanded directories
45
+ * appear under their parent, collapsed subtrees are omitted.
46
+ */
47
+ export declare function flattenTree(root: FileNode, expanded: ReadonlySet<string>): TreeRow[];
48
+ /** Expand a directory, or collapse it when already expanded. */
49
+ export declare function toggleExpanded(expanded: Set<string>, path: string): void;
@@ -0,0 +1,129 @@
1
+ import fs from 'node:fs/promises';
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 async function scanDirectoryCached(root) {
17
+ const hit = scanCache.get(root);
18
+ if (hit) {
19
+ return {
20
+ ok: true,
21
+ node: hit,
22
+ path: root
23
+ };
24
+ }
25
+ const tree = await scanDirectory(root);
26
+ if (tree) {
27
+ scanCache.set(root, tree);
28
+ return {
29
+ ok: true,
30
+ node: tree,
31
+ path: root
32
+ };
33
+ }
34
+ return {
35
+ fail: true
36
+ };
37
+ }
38
+ /** Directories skipped at every depth (dependency dirs, VCS internals). */
39
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".next", ".cache"]);
40
+ /** Any entry starting with "." is hidden (dotfiles) and skipped. */
41
+ const isHidden = (name) => name.startsWith(".");
42
+ /** Expand a leading `~` to the user's home directory. */
43
+ export function expandHome(path) {
44
+ return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
45
+ }
46
+ /** Resolve the file-tree root from settings: startup dir or custom path. */
47
+ export function resolveFileTreeRoot(settings, startupDir) {
48
+ if (settings.root === "custom") {
49
+ const custom = expandHome(settings.customPath.trim());
50
+ return custom.length > 0 ? custom : null;
51
+ }
52
+ return startupDir;
53
+ }
54
+ /**
55
+ * Recursively scan a directory into a tree. Symlinks are never followed
56
+ * (they can loop), dotfiles and known dependency/VCS dirs are skipped.
57
+ * Directories sort before files, both alphabetically.
58
+ *
59
+ * @returns The root node, or null when the directory cannot be read.
60
+ */
61
+ export async function scanDirectory(root) {
62
+ const entries = await readDirSafe(root);
63
+ if (!entries) {
64
+ return null;
65
+ }
66
+ const nodes = [];
67
+ for (const entry of entries) {
68
+ const name = entry.name;
69
+ if (isHidden(name) || entry.isSymbolicLink()) {
70
+ continue;
71
+ }
72
+ const path = join(root, name);
73
+ if (entry.isDirectory()) {
74
+ if (SKIP_DIRS.has(name)) {
75
+ continue;
76
+ }
77
+ nodes.push({ name, path, isDir: true, children: await scanChildren(path) });
78
+ }
79
+ else if (entry.isFile()) {
80
+ nodes.push({ name, path, isDir: false });
81
+ }
82
+ }
83
+ // Code-unit compare, not localeCompare: deterministic across locales
84
+ // (localeCompare reorders by the host's locale).
85
+ nodes.sort((a, b) => a.isDir === b.isDir ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.isDir ? -1 : 1);
86
+ return { name: root, path: root, isDir: true, children: nodes };
87
+ }
88
+ async function scanChildren(dir) {
89
+ const child = await scanDirectory(dir);
90
+ return child?.children;
91
+ }
92
+ /** Read a directory without throwing; null when unreadable (EACCES etc.). */
93
+ async function readDirSafe(path) {
94
+ try {
95
+ return await fs.readdir(path, { withFileTypes: true });
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /**
102
+ * Flatten the tree into the rows to render: children of expanded directories
103
+ * appear under their parent, collapsed subtrees are omitted.
104
+ */
105
+ export function flattenTree(root, expanded) {
106
+ const rows = [];
107
+ const walk = (node, depth) => {
108
+ rows.push({ node, depth });
109
+ if (node.isDir && node.children && expanded.has(node.path)) {
110
+ for (const child of node.children) {
111
+ walk(child, depth + 1);
112
+ }
113
+ }
114
+ };
115
+ // The root itself is never rendered (it is the pane's title area).
116
+ for (const child of root.children ?? []) {
117
+ walk(child, 1);
118
+ }
119
+ return rows;
120
+ }
121
+ /** Expand a directory, or collapse it when already expanded. */
122
+ export function toggleExpanded(expanded, path) {
123
+ if (expanded.has(path)) {
124
+ expanded.delete(path);
125
+ }
126
+ else {
127
+ expanded.add(path);
128
+ }
129
+ }
@@ -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