@tooee/view 0.12.0 → 0.14.0

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 (93) hide show
  1. package/dist/components/subviews/diff-subview.d.ts.map +1 -1
  2. package/dist/components/subviews/diff-subview.js +16 -4
  3. package/dist/components/subviews/diff-subview.js.map +1 -1
  4. package/dist/components/subviews/image-subview.d.ts.map +1 -1
  5. package/dist/components/subviews/image-subview.js +2 -1
  6. package/dist/components/subviews/image-subview.js.map +1 -1
  7. package/dist/components/subviews/index.d.ts.map +1 -1
  8. package/dist/components/subviews/index.js.map +1 -1
  9. package/dist/components/subviews/markdown-subview.d.ts.map +1 -1
  10. package/dist/components/subviews/markdown-subview.js +3 -2
  11. package/dist/components/subviews/markdown-subview.js.map +1 -1
  12. package/dist/components/subviews/table-subview.d.ts.map +1 -1
  13. package/dist/components/subviews/table-subview.js +4 -3
  14. package/dist/components/subviews/table-subview.js.map +1 -1
  15. package/dist/components/view-screen.d.ts.map +1 -1
  16. package/dist/components/view-screen.js +11 -1
  17. package/dist/components/view-screen.js.map +1 -1
  18. package/dist/content-loader-store.d.ts.map +1 -1
  19. package/dist/content-loader-store.js.map +1 -1
  20. package/dist/default-provider.d.ts.map +1 -1
  21. package/dist/default-provider.js.map +1 -1
  22. package/dist/directory-provider.d.ts.map +1 -1
  23. package/dist/directory-provider.js.map +1 -1
  24. package/dist/directory-view.d.ts.map +1 -1
  25. package/dist/directory-view.js +5 -2
  26. package/dist/directory-view.js.map +1 -1
  27. package/dist/folds/fold-decorations.d.ts.map +1 -1
  28. package/dist/folds/fold-decorations.js.map +1 -1
  29. package/dist/folds/fold-model.d.ts.map +1 -1
  30. package/dist/folds/fold-model.js +3 -1
  31. package/dist/folds/fold-model.js.map +1 -1
  32. package/dist/folds/markdown-heading-folds.d.ts.map +1 -1
  33. package/dist/folds/markdown-heading-folds.js +1 -6
  34. package/dist/folds/markdown-heading-folds.js.map +1 -1
  35. package/dist/folds/use-folds.d.ts.map +1 -1
  36. package/dist/folds/use-folds.js.map +1 -1
  37. package/dist/hooks/use-content-document.d.ts.map +1 -1
  38. package/dist/hooks/use-content-document.js +3 -2
  39. package/dist/hooks/use-content-document.js.map +1 -1
  40. package/dist/hooks/use-content-loader.d.ts.map +1 -1
  41. package/dist/hooks/use-content-loader.js.map +1 -1
  42. package/dist/hooks/use-view-command-context.d.ts.map +1 -1
  43. package/dist/hooks/use-view-command-context.js.map +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/launch.d.ts +7 -1
  47. package/dist/launch.d.ts.map +1 -1
  48. package/dist/launch.js.map +1 -1
  49. package/dist/link-handlers.d.ts.map +1 -1
  50. package/dist/link-handlers.js.map +1 -1
  51. package/dist/markdown-links.d.ts.map +1 -1
  52. package/dist/markdown-links.js.map +1 -1
  53. package/dist/outline/markdown-outline.d.ts.map +1 -1
  54. package/dist/outline/markdown-outline.js.map +1 -1
  55. package/dist/outline/outline-panel.d.ts.map +1 -1
  56. package/dist/outline/outline-panel.js.map +1 -1
  57. package/dist/outline/use-outline.d.ts.map +1 -1
  58. package/dist/outline/use-outline.js.map +1 -1
  59. package/dist/standalone-router.d.ts.map +1 -1
  60. package/dist/standalone-router.js +3 -0
  61. package/dist/standalone-router.js.map +1 -1
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js.map +1 -1
  64. package/dist/view.d.ts.map +1 -1
  65. package/dist/view.js.map +1 -1
  66. package/package.json +14 -14
  67. package/src/components/subviews/diff-subview.tsx +21 -4
  68. package/src/components/subviews/image-subview.tsx +2 -1
  69. package/src/components/subviews/index.ts +6 -0
  70. package/src/components/subviews/markdown-subview.tsx +12 -2
  71. package/src/components/subviews/table-subview.tsx +6 -3
  72. package/src/components/view-screen.tsx +11 -1
  73. package/src/content-loader-store.ts +23 -0
  74. package/src/default-provider.ts +18 -0
  75. package/src/directory-provider.ts +7 -0
  76. package/src/directory-view.tsx +7 -2
  77. package/src/folds/fold-decorations.ts +4 -0
  78. package/src/folds/fold-model.ts +8 -1
  79. package/src/folds/markdown-heading-folds.ts +7 -6
  80. package/src/folds/use-folds.ts +18 -0
  81. package/src/hooks/use-content-document.ts +5 -2
  82. package/src/hooks/use-content-loader.ts +7 -0
  83. package/src/hooks/use-view-command-context.ts +1 -0
  84. package/src/index.ts +17 -0
  85. package/src/launch.tsx +9 -1
  86. package/src/link-handlers.ts +10 -0
  87. package/src/markdown-links.ts +16 -0
  88. package/src/outline/markdown-outline.ts +3 -0
  89. package/src/outline/outline-panel.tsx +13 -0
  90. package/src/outline/use-outline.ts +24 -0
  91. package/src/standalone-router.tsx +23 -2
  92. package/src/types.ts +11 -0
  93. package/src/view.tsx +6 -0
@@ -52,15 +52,19 @@ export const createEmptyContent = function createEmptyContent(
52
52
  case "markdown": {
53
53
  return { format, markdown: "", title };
54
54
  }
55
+
55
56
  case "code": {
56
57
  return { code: "", format, title };
57
58
  }
59
+
58
60
  case "text": {
59
61
  return { format, text: "", title };
60
62
  }
63
+
61
64
  case "table": {
62
65
  return { columns: [], format, rows: [], title };
63
66
  }
67
+
64
68
  default: {
65
69
  return { data: undefined, format, title };
66
70
  }
@@ -77,6 +81,7 @@ const ensureContentFormat = function ensureContentFormat<F extends ContentFormat
77
81
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- format-dependent content is trusted at this provider boundary
78
82
  return createEmptyContent(format, title) as Extract<Content, { format: F }>;
79
83
  }
84
+
80
85
  // SAFETY: The equality guard narrows current to the built-in content variant selected by format.
81
86
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the provider contract supplies the matching format
82
87
  return current as Extract<Content, { format: F }>;
@@ -91,30 +96,39 @@ export const applyContentChunk = function applyContentChunk(
91
96
  case "replace": {
92
97
  return chunk.content;
93
98
  }
99
+
94
100
  case "append": {
95
101
  if (chunk.format === "markdown") {
96
102
  const target = ensureContentFormat(current, "markdown", title);
103
+
97
104
  return { ...target, markdown: target.markdown + chunk.data };
98
105
  }
106
+
99
107
  if (chunk.format === "code") {
100
108
  const target = ensureContentFormat(current, "code", title);
109
+
101
110
  return {
102
111
  ...target,
103
112
  code: target.code + chunk.data,
104
113
  language: chunk.language ?? target.language,
105
114
  };
106
115
  }
116
+
107
117
  {
108
118
  const target = ensureContentFormat(current, "text", title);
119
+
109
120
  return { ...target, text: target.text + chunk.data };
110
121
  }
111
122
  }
123
+
112
124
  case "patch": {
113
125
  return chunk.apply(current);
114
126
  }
127
+
115
128
  case "marks": {
116
129
  return current ?? createEmptyContent("markdown", title);
117
130
  }
131
+
118
132
  default: {
119
133
  return current ?? createEmptyContent("markdown", title);
120
134
  }
@@ -141,14 +155,18 @@ export const createContentLoaderStore = function createContentLoaderStore() {
141
155
  if (event.requestId !== ctx.requestId) {
142
156
  return ctx;
143
157
  }
158
+
144
159
  if (event.chunk.type === "marks") {
145
160
  const markSet = event.chunk.set;
161
+
146
162
  const providerMarks = [
147
163
  ...ctx.providerMarks.filter((set) => set.namespace !== markSet.namespace),
148
164
  markSet,
149
165
  ];
166
+
150
167
  return { ...ctx, providerMarks };
151
168
  }
169
+
152
170
  return { ...ctx, content: applyContentChunk(ctx.content, event.chunk, ctx.title) };
153
171
  },
154
172
  loadCancelled: (ctx, event) =>
@@ -183,8 +201,13 @@ export const createContentLoaderStore = function createContentLoaderStore() {
183
201
  };
184
202
 
185
203
  export const selectContent = (ctx: ContentLoaderContext) => ctx.content;
204
+
186
205
  export const selectStatus = (ctx: ContentLoaderContext) => ctx.status;
206
+
187
207
  export const selectStreaming = (ctx: ContentLoaderContext) => ctx.status === "streaming";
208
+
188
209
  export const selectError = (ctx: ContentLoaderContext) => ctx.error;
210
+
189
211
  export const selectProviderMarks = (ctx: ContentLoaderContext) => ctx.providerMarks;
212
+
190
213
  export const selectLoadSeq = (ctx: ContentLoaderContext) => ctx.loadSeq;
@@ -16,26 +16,31 @@ interface DetectedFileFormat {
16
16
 
17
17
  const detectFormat = function detectFormat(filePath: string): DetectedFileFormat {
18
18
  const ext = filePath.split(".").pop()?.toLowerCase();
19
+
19
20
  if (ext === undefined || ext === "") {
20
21
  return { format: "text" };
21
22
  }
22
23
 
23
24
  const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp"]);
25
+
24
26
  if (imageExts.has(ext)) {
25
27
  return { format: "image" };
26
28
  }
27
29
 
28
30
  const tableExts = new Set(["csv", "tsv"]);
31
+
29
32
  if (tableExts.has(ext)) {
30
33
  return { format: "table" };
31
34
  }
32
35
 
33
36
  const diffExts = new Set(["diff", "patch"]);
37
+
34
38
  if (diffExts.has(ext)) {
35
39
  return { format: "diff" };
36
40
  }
37
41
 
38
42
  const markdownExts = new Set(["md", "mdx", "markdown"]);
43
+
39
44
  if (markdownExts.has(ext)) {
40
45
  return { format: "markdown" };
41
46
  }
@@ -69,6 +74,7 @@ const detectFormat = function detectFormat(filePath: string): DetectedFileFormat
69
74
  ]);
70
75
 
71
76
  const language = codeExts.get(ext);
77
+
72
78
  if (language !== undefined) {
73
79
  return { format: "code", language };
74
80
  }
@@ -86,22 +92,29 @@ const contentFromText = function contentFromText(
86
92
  case "markdown": {
87
93
  return { format: "markdown", markdown: text, title };
88
94
  }
95
+
89
96
  case "code": {
90
97
  return { code: text, format: "code", language, title };
91
98
  }
99
+
92
100
  case "diff": {
93
101
  return { format: "diff", patch: text, title };
94
102
  }
103
+
95
104
  case "table": {
96
105
  const parsed = parseAuto(text);
106
+
97
107
  return { columns: parsed.columns, format: "table", rows: parsed.rows, title };
98
108
  }
109
+
99
110
  case "text": {
100
111
  return { format: "text", text, title };
101
112
  }
113
+
102
114
  case "image": {
103
115
  return { format: "image", src: text.trim(), title };
104
116
  }
117
+
105
118
  default: {
106
119
  return { format: "text", text, title };
107
120
  }
@@ -124,15 +137,19 @@ export const createFileProvider = function createFileProvider(
124
137
 
125
138
  const file = Bun.file(filePath);
126
139
  const text = await file.text();
140
+
127
141
  // Patches are routinely written to files with no telling extension.
128
142
  const resolved =
129
143
  options.renderer === undefined && detected.format === "text" && isDiffPatch(text)
130
144
  ? "diff"
131
145
  : format;
146
+
132
147
  const content = contentFromText(text, resolved, title, detected.language);
148
+
133
149
  if (content.format === "markdown") {
134
150
  content.imageBasePath = path.dirname(path.resolve(filePath));
135
151
  }
152
+
136
153
  return content;
137
154
  },
138
155
  };
@@ -145,6 +162,7 @@ export const createStdinProvider = function createStdinProvider(
145
162
  async load(): Promise<Content> {
146
163
  const text = await new Response(Bun.stdin.stream()).text();
147
164
  const format = options.renderer ?? (isDiffPatch(text) ? "diff" : "markdown");
165
+
148
166
  return contentFromText(text, format, "stdin");
149
167
  },
150
168
  };
@@ -58,23 +58,30 @@ export const listDirectoryFiles = function listDirectoryFiles(dirPath: string):
58
58
  if (entry.startsWith(".")) {
59
59
  continue;
60
60
  }
61
+
61
62
  const fullPath = path.join(dirPath, entry);
63
+
62
64
  try {
63
65
  const stat = statSync(fullPath);
66
+
64
67
  if (!stat.isFile()) {
65
68
  continue;
66
69
  }
67
70
  } catch {
68
71
  continue;
69
72
  }
73
+
70
74
  const ext = entry.split(".").pop()?.toLowerCase();
75
+
71
76
  if (ext === undefined || ext === "" || !SUPPORTED_EXTENSIONS.has(ext)) {
72
77
  continue;
73
78
  }
79
+
74
80
  files.push({ name: entry, path: fullPath });
75
81
  }
76
82
 
77
83
  files.sort((a, b) => a.name.localeCompare(b.name));
84
+
78
85
  return files;
79
86
  };
80
87
 
@@ -16,6 +16,7 @@ const createDirectoryFileProvider = function createDirectoryFileProvider(
16
16
  total: number
17
17
  ): ContentProvider {
18
18
  const inner = createFileProvider(entry.path);
19
+
19
20
  return {
20
21
  async load(): Promise<AnyContent> {
21
22
  const result = inner.load();
@@ -23,6 +24,7 @@ const createDirectoryFileProvider = function createDirectoryFileProvider(
23
24
  // or through a Promise; it never returns the ContentProvider async-iterable arm.
24
25
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- file providers return external content at this boundary
25
26
  const content = result instanceof Promise ? await result : (result as AnyContent);
27
+
26
28
  return {
27
29
  ...content,
28
30
  title: `${entry.name} (${index + 1}/${total})`,
@@ -51,11 +53,14 @@ export const DirectoryView = function DirectoryView({
51
53
  [files, currentIndex]
52
54
  );
53
55
 
56
+ // File keys use a `g` sequence. `h` and `l` belong to horizontal scroll in
57
+ // the markdown and diff subviews, and two commands on one key let mount
58
+ // order pick the winner.
54
59
  useCommand({
55
60
  handler: () => {
56
61
  setCurrentIndex((i) => Math.min(i + 1, files.length - 1));
57
62
  },
58
- hotkey: "l",
63
+ hotkey: "g n",
59
64
  id: "dir.next-file",
60
65
  modes: ["cursor"],
61
66
  title: "Next file",
@@ -65,7 +70,7 @@ export const DirectoryView = function DirectoryView({
65
70
  handler: () => {
66
71
  setCurrentIndex((i) => Math.max(i - 1, 0));
67
72
  },
68
- hotkey: "h",
73
+ hotkey: "g p",
69
74
  id: "dir.prev-file",
70
75
  modes: ["cursor"],
71
76
  title: "Previous file",
@@ -11,15 +11,19 @@ export const projectDecorationLayers = function projectDecorationLayers(
11
11
  view: FoldView
12
12
  ): DecorationLayer[] {
13
13
  const { visibleToSource, sourceToVisible } = view;
14
+
14
15
  return layers.map((layer): DecorationLayer => ({
15
16
  *forVisibleRows(from: number, to: number): Generator<RowDecoration> {
16
17
  const sourceFrom = visibleToSource[from];
17
18
  const sourceTo = visibleToSource[Math.min(to, visibleToSource.length - 1)];
19
+
18
20
  if (sourceFrom === undefined || sourceTo === undefined) {
19
21
  return;
20
22
  }
23
+
21
24
  for (const decoration of layer.forVisibleRows(sourceFrom, sourceTo)) {
22
25
  const row = sourceToVisible[decoration.row] ?? -1;
26
+
23
27
  if (row !== -1) {
24
28
  yield { ...decoration, row };
25
29
  }
@@ -33,6 +33,7 @@ export const computeFoldView = function computeFoldView(
33
33
  closedKeys: ReadonlySet<Key>
34
34
  ): FoldView {
35
35
  const closedEnds = new Map<number, number>();
36
+
36
37
  for (const range of ranges) {
37
38
  if (range.end > range.start && closedKeys.has(range.key)) {
38
39
  closedEnds.set(range.start, Math.max(closedEnds.get(range.start) ?? range.end, range.end));
@@ -43,14 +44,17 @@ export const computeFoldView = function computeFoldView(
43
44
  const sourceToVisible: number[] = Array.from({ length: rowCount }, () => -1);
44
45
  const hiddenCounts = new Map<number, number>();
45
46
  let hiddenUntil = -1;
47
+
46
48
  for (let index = 0; index < rowCount; index += 1) {
47
49
  if (index <= hiddenUntil) {
48
50
  continue;
49
51
  }
52
+
50
53
  const visibleIndex = visibleToSource.length;
51
54
  visibleToSource.push(index);
52
55
  sourceToVisible[index] = visibleIndex;
53
56
  const end = closedEnds.get(index);
57
+
54
58
  if (end !== undefined) {
55
59
  hiddenUntil = Math.min(end, rowCount - 1);
56
60
  hiddenCounts.set(visibleIndex, hiddenUntil - index);
@@ -80,8 +84,10 @@ export const closedFoldsHiding = function closedFoldsHiding(
80
84
  row: number
81
85
  ): Key[] {
82
86
  return ranges
87
+ .values()
83
88
  .filter((range) => closedKeys.has(range.key) && range.start < row && row <= range.end)
84
- .map((range) => range.key);
89
+ .map((range) => range.key)
90
+ .toArray();
85
91
  };
86
92
 
87
93
  /**
@@ -95,5 +101,6 @@ export const visibleAnchorRow = function visibleAnchorRow(view: FoldView, row: n
95
101
  return index;
96
102
  }
97
103
  }
104
+
98
105
  return row;
99
106
  };
@@ -5,12 +5,7 @@ import type { FoldRange } from "./fold-model.js";
5
5
 
6
6
  /** The Marked heading token of a flattened block, or `null` when the block is not a heading. */
7
7
  export const headingToken = function headingToken(block: FlatBlock): Tokens.Heading | null {
8
- if (block.token.type !== "heading") {
9
- return null;
10
- }
11
- // SAFETY: Marked creates a Heading token for the checked "heading" discriminator.
12
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Marked's Generic fallback prevents discriminator narrowing
13
- return block.token as Tokens.Heading;
8
+ return block.kind === "heading" ? block.token : null;
14
9
  };
15
10
 
16
11
  const headingDepth = function headingDepth(block: FlatBlock): number | null {
@@ -28,6 +23,7 @@ export const markdownHeadingFoldRanges = function markdownHeadingFoldRanges(
28
23
  ): FoldRange[] {
29
24
  const ranges: FoldRange[] = [];
30
25
  const open: { depth: number; start: number }[] = [];
26
+
31
27
  const close = (start: number, end: number) => {
32
28
  if (end > start) {
33
29
  ranges.push({ end, key: start, start });
@@ -36,17 +32,22 @@ export const markdownHeadingFoldRanges = function markdownHeadingFoldRanges(
36
32
 
37
33
  for (const [index, block] of blocks.entries()) {
38
34
  const depth = headingDepth(block);
35
+
39
36
  if (depth === null) {
40
37
  continue;
41
38
  }
39
+
42
40
  while (open.length > 0 && (open.at(-1)?.depth ?? 0) >= depth) {
43
41
  const finished = open.pop();
42
+
44
43
  if (finished) {
45
44
  close(finished.start, index - 1);
46
45
  }
47
46
  }
47
+
48
48
  open.push({ depth, start: index });
49
49
  }
50
+
50
51
  for (const finished of open) {
51
52
  close(finished.start, blocks.length - 1);
52
53
  }
@@ -39,10 +39,12 @@ export const useFoldState = function useFoldState<T>(
39
39
  decorations: DecorationLayer[]
40
40
  ): FoldState<T> {
41
41
  const [closedKeys, setClosedKeys] = useState<ReadonlySet<Key>>(EMPTY_KEYS);
42
+
42
43
  const view = useMemo(
43
44
  () => computeFoldView(rows.length, ranges, closedKeys),
44
45
  [rows.length, ranges, closedKeys]
45
46
  );
47
+
46
48
  const folded = view.visibleToSource.length < rows.length;
47
49
 
48
50
  const visibleRows = useMemo(
@@ -50,15 +52,18 @@ export const useFoldState = function useFoldState<T>(
50
52
  folded
51
53
  ? view.visibleToSource.flatMap((index) => {
52
54
  const row = rows[index];
55
+
53
56
  return row === undefined ? [] : [row];
54
57
  })
55
58
  : rows,
56
59
  [folded, rows, view]
57
60
  );
61
+
58
62
  const visibleDecorations = useMemo(
59
63
  () => (folded ? projectDecorationLayers(decorations, view) : decorations),
60
64
  [folded, decorations, view]
61
65
  );
66
+
62
67
  const rowNumbers = useMemo(
63
68
  () => (folded ? view.visibleToSource.map((index) => index + 1) : undefined),
64
69
  [folded, view]
@@ -109,27 +114,36 @@ export const useFoldActions = function useFoldActions<T>(
109
114
  if (cursorRow !== undefined) {
110
115
  const anchor = visibleAnchorRow(computeFoldView(rowCount, ranges, next), cursorRow);
111
116
  const anchorIndex = view.sourceToVisible[anchor] ?? -1;
117
+
112
118
  if (anchor !== cursorRow && anchorIndex !== -1) {
113
119
  setCursor(anchorIndex);
114
120
  }
115
121
  }
122
+
116
123
  setClosedKeys(next);
117
124
  };
125
+
118
126
  const withKey = (key: Key | undefined, closed: boolean): boolean => {
119
127
  if (key === undefined) {
120
128
  return false;
121
129
  }
130
+
122
131
  const next = new Set(closedKeys);
132
+
123
133
  if (closed) {
124
134
  next.add(key);
125
135
  } else {
126
136
  next.delete(key);
127
137
  }
138
+
128
139
  apply(next);
140
+
129
141
  return true;
130
142
  };
143
+
131
144
  const close = (row: number) =>
132
145
  withKey(foldsAt(ranges, row).find((range) => !closedKeys.has(range.key))?.key, true);
146
+
133
147
  const open = (row: number) =>
134
148
  withKey(foldsAt(ranges, row).find((range) => closedKeys.has(range.key))?.key, false);
135
149
 
@@ -144,6 +158,7 @@ export const useFoldActions = function useFoldActions<T>(
144
158
  },
145
159
  toggle: (row) => {
146
160
  const [innermost] = foldsAt(ranges, row);
161
+
147
162
  return withKey(innermost?.key, innermost !== undefined && !closedKeys.has(innermost.key));
148
163
  },
149
164
  toggleBlock: (row) =>
@@ -168,10 +183,12 @@ export const foldActionDefinitions = function foldActionDefinitions(
168
183
  (run: (row: number) => boolean) =>
169
184
  (ctx: CommandContext): void => {
170
185
  const row = targetRow();
186
+
171
187
  if (row === undefined || !run(row)) {
172
188
  ctx.toast?.toast({ level: "warning", message: noFoldMessage });
173
189
  }
174
190
  };
191
+
175
192
  const definitions: [hotkey: string, id: string, title: string, handler: CommandHandler][] = [
176
193
  ["z a", "toggle", "Toggle fold", atTarget(actions.toggle)],
177
194
  ["z c", "close", "Close fold", atTarget(actions.close)],
@@ -180,6 +197,7 @@ export const foldActionDefinitions = function foldActionDefinitions(
180
197
  ["z shift+m", "close-all", "Close all folds", actions.closeAll],
181
198
  ["z shift+r", "open-all", "Open all folds", actions.openAll],
182
199
  ];
200
+
183
201
  return definitions.map(([hotkey, id, title, handler]) => ({
184
202
  category: "Fold",
185
203
  handler,
@@ -1,4 +1,5 @@
1
1
  import type { ActionDefinition } from "@tooee/commands";
2
+ import { StatusPriority } from "@tooee/layout";
2
3
  import type { StatusBarItem } from "@tooee/layout";
3
4
  import type { DecorationLayer } from "@tooee/renderers";
4
5
  import { useDocumentController } from "@tooee/shell";
@@ -37,6 +38,7 @@ export const useContentDocument = function useContentDocument<T>(
37
38
  options: ContentDocumentOptions<T>
38
39
  ): ContentDocumentResult<T> {
39
40
  const { showLineNumbers } = useContentCommands({ content, textContent });
41
+
40
42
  const document = useDocumentController<T>({
41
43
  adapter,
42
44
  contextMenu: options.contextMenu === false ? false : actions,
@@ -45,10 +47,11 @@ export const useContentDocument = function useContentDocument<T>(
45
47
  preserveCursorByKey: options.preserveCursorByKey,
46
48
  rows,
47
49
  });
50
+
48
51
  const statusItems = options.buildStatusItems?.(document) ??
49
52
  options.statusItems ?? [
50
- { label: "Format:", value: content.format },
51
- { label: "Lines:", value: String(rows.length) },
53
+ { label: "Format:", priority: StatusPriority.content, value: content.format },
54
+ { label: "Lines:", priority: StatusPriority.content, value: String(rows.length) },
52
55
  ];
53
56
 
54
57
  return { document, showLineNumbers, statusItems };
@@ -39,6 +39,7 @@ export const useContentLoader = function useContentLoader(
39
39
  });
40
40
  const { requestId } = store.getSnapshot().context;
41
41
  const loaded = contentProvider.load();
42
+
42
43
  let cleanup: () => void = () => {
43
44
  // Synchronous providers have no pending work to cancel.
44
45
  };
@@ -55,16 +56,20 @@ export const useContentLoader = function useContentLoader(
55
56
  // preserve sequential stream consumption for ordered backpressure.
56
57
  // oxlint-disable-next-line no-await-in-loop -- each chunk must be consumed in iterator order
57
58
  const result = await iterator.next();
59
+
58
60
  if (result.done === true) {
59
61
  break;
60
62
  }
63
+
61
64
  store.trigger.chunkReceived({ chunk: result.value, requestId });
62
65
  }
66
+
63
67
  store.trigger.streamEnded({ requestId });
64
68
  } catch (error) {
65
69
  store.trigger.loadFailed({ error: normalizeError(error), requestId });
66
70
  }
67
71
  })();
72
+
68
73
  cleanup = () => {
69
74
  store.trigger.loadCancelled({ requestId });
70
75
  void (async () => {
@@ -84,6 +89,7 @@ export const useContentLoader = function useContentLoader(
84
89
  store.trigger.loadFailed({ error: normalizeError(error), requestId });
85
90
  }
86
91
  })();
92
+
87
93
  cleanup = () => {
88
94
  store.trigger.loadCancelled({ requestId });
89
95
  };
@@ -99,6 +105,7 @@ export const useContentLoader = function useContentLoader(
99
105
  const error = useSelector(store, (snapshot) => selectError(snapshot.context));
100
106
  const providerMarks = useSelector(store, (snapshot) => selectProviderMarks(snapshot.context));
101
107
  const status = useSelector(store, (snapshot) => selectStatus(snapshot.context));
108
+
102
109
  const reload = useCallback(() => {
103
110
  store.trigger.reloadRequested({});
104
111
  }, [store]);
@@ -72,6 +72,7 @@ export const createViewCommandContext = function createViewCommandContext({
72
72
  marks,
73
73
  }: CreateViewCommandContextOptions): ViewCommandContext {
74
74
  const resolvedFormat = format ?? content?.format ?? "custom";
75
+
75
76
  const resolvedContent: AnyContent =
76
77
  content ??
77
78
  ({
package/src/index.ts CHANGED
@@ -1,27 +1,39 @@
1
1
  export { View } from "./view.js";
2
+
2
3
  export { DirectoryView } from "./directory-view.js";
4
+
3
5
  export { launch, launchDirectory } from "./launch.js";
6
+
4
7
  export type { ViewLaunchOptions } from "./launch.js";
8
+
5
9
  export {
6
10
  createFileProvider,
7
11
  createStdinProvider,
8
12
  createTableFileProvider,
9
13
  createTableStdinProvider,
10
14
  } from "./default-provider.js";
15
+
11
16
  export type { CreateProviderOptions } from "./default-provider.js";
17
+
12
18
  export { listDirectoryFiles } from "./directory-provider.js";
19
+
13
20
  export type { DirectoryEntry } from "./directory-provider.js";
21
+
14
22
  export {
15
23
  createViewCommandContext,
16
24
  useProvideViewCommandContext,
17
25
  } from "./hooks/use-view-command-context.js";
26
+
18
27
  export type {
19
28
  CreateViewCommandContextOptions,
20
29
  ProvideViewCommandContextOptions,
21
30
  ViewCommandContext,
22
31
  } from "./hooks/use-view-command-context.js";
32
+
23
33
  export type { ContentLoaderState } from "./hooks/use-content-loader.js";
34
+
24
35
  export type { ContentCommandsResult } from "./hooks/use-content-commands.js";
36
+
25
37
  export type {
26
38
  AnyContent,
27
39
  Content,
@@ -43,8 +55,13 @@ export type {
43
55
  ColumnDef,
44
56
  TableRow,
45
57
  } from "./types.js";
58
+
46
59
  export { getTextContent, isBuiltinContent, isCustomContent } from "./types.js";
60
+
47
61
  export { CodeBlockChrome } from "@tooee/renderers";
62
+
48
63
  export type { CodeBlockRenderer, CodeBlockRendererProps, CodeBlockHScroll } from "@tooee/renderers";
64
+
49
65
  export { localFileLinkHandler } from "./link-handlers.js";
66
+
50
67
  export type { LinkHandler, LinkHandlerContext, LinkTarget } from "./link-handlers.js";
package/src/launch.tsx CHANGED
@@ -7,7 +7,7 @@ import type { ReactNode } from "react";
7
7
  import { DirectoryView } from "./directory-view.js";
8
8
  import type { LinkHandler } from "./link-handlers.js";
9
9
  import { createStandaloneRouter } from "./standalone-router.js";
10
- import type { ContentProvider, ContentRenderer } from "./types.js";
10
+ import type { ContentProvider, ContentRenderer, MarkdownLinkActivateHandler } from "./types.js";
11
11
 
12
12
  export interface ViewLaunchOptions {
13
13
  contentProvider: ContentProvider;
@@ -15,6 +15,12 @@ export interface ViewLaunchOptions {
15
15
  filePath?: string;
16
16
  /** Synchronous handlers tried before local file navigation. */
17
17
  linkHandlers?: readonly LinkHandler[];
18
+ /**
19
+ * Handles activation of a Markdown link, by mouse or by Enter on the cursor
20
+ * line. When set, it replaces the default handling: `linkHandlers`, local
21
+ * file navigation, and the stdin "Links need a source file" toast.
22
+ */
23
+ onMarkdownLinkActivate?: MarkdownLinkActivateHandler;
18
24
  actions?: ActionDefinition[];
19
25
  renderers?: Record<string, ContentRenderer>;
20
26
  /**
@@ -35,9 +41,11 @@ export interface DirectoryLaunchOptions {
35
41
  export const launch = async function launch(options: ViewLaunchOptions): Promise<void> {
36
42
  const { router } = createStandaloneRouter(options);
37
43
  const startup = await router.start();
44
+
38
45
  if (startup.status !== "committed") {
39
46
  throw new Error(`View router startup ${startup.status}`);
40
47
  }
48
+
41
49
  await runCliSession<undefined>(
42
50
  (): ReactNode => (
43
51
  <RouterProvider router={router}>
@@ -24,22 +24,29 @@ export type LinkHandler = (link: LinkTarget, context: LinkHandlerContext) => boo
24
24
 
25
25
  export const localFileLinkHandler: LinkHandler = function localFileLinkHandler(link, context) {
26
26
  const result = resolveMarkdownLink(link.href, link.baseDir, link.currentPath);
27
+
27
28
  if (result.status === "unsupported") {
28
29
  return false;
29
30
  }
31
+
30
32
  if (result.status === "file") {
31
33
  void context.navigate.push(context.documentRoute, { path: result.path });
34
+
32
35
  return true;
33
36
  }
37
+
34
38
  const relative = path.relative(link.baseDir, result.path);
39
+
35
40
  const target =
36
41
  relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
37
42
  ? result.path
38
43
  : relative || ".";
44
+
39
45
  context.command.toast?.toast({
40
46
  level: "warning",
41
47
  message: `${result.status === "missing" ? "File not found" : "Not a file"}: ${target}`,
42
48
  });
49
+
43
50
  return true;
44
51
  };
45
52
 
@@ -54,9 +61,12 @@ export const runLinkHandlers = function runLinkHandlers(
54
61
  return true;
55
62
  }
56
63
  }
64
+
57
65
  if (localFileLinkHandler(link, context)) {
58
66
  return true;
59
67
  }
68
+
60
69
  context.command.toast?.toast({ level: "warning", message: `Unsupported link: ${link.href}` });
70
+
61
71
  return true;
62
72
  };