@tooee/view 0.7.3 → 0.8.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 (89) hide show
  1. package/dist/components/subviews/code-subview.d.ts +2 -1
  2. package/dist/components/subviews/code-subview.d.ts.map +1 -1
  3. package/dist/components/subviews/code-subview.js +2 -15
  4. package/dist/components/subviews/code-subview.js.map +1 -1
  5. package/dist/components/subviews/custom-subview.d.ts +2 -1
  6. package/dist/components/subviews/custom-subview.d.ts.map +1 -1
  7. package/dist/components/subviews/custom-subview.js +3 -13
  8. package/dist/components/subviews/custom-subview.js.map +1 -1
  9. package/dist/components/subviews/diff-subview.d.ts +2 -1
  10. package/dist/components/subviews/diff-subview.d.ts.map +1 -1
  11. package/dist/components/subviews/diff-subview.js +23 -33
  12. package/dist/components/subviews/diff-subview.js.map +1 -1
  13. package/dist/components/subviews/image-subview.d.ts +2 -1
  14. package/dist/components/subviews/image-subview.d.ts.map +1 -1
  15. package/dist/components/subviews/image-subview.js +4 -9
  16. package/dist/components/subviews/image-subview.js.map +1 -1
  17. package/dist/components/subviews/markdown-subview.d.ts +2 -1
  18. package/dist/components/subviews/markdown-subview.d.ts.map +1 -1
  19. package/dist/components/subviews/markdown-subview.js +8 -15
  20. package/dist/components/subviews/markdown-subview.js.map +1 -1
  21. package/dist/components/subviews/table-subview.d.ts +2 -1
  22. package/dist/components/subviews/table-subview.d.ts.map +1 -1
  23. package/dist/components/subviews/table-subview.js +9 -48
  24. package/dist/components/subviews/table-subview.js.map +1 -1
  25. package/dist/components/view-state.d.ts +2 -1
  26. package/dist/components/view-state.d.ts.map +1 -1
  27. package/dist/components/view-state.js.map +1 -1
  28. package/dist/content-loader-store.d.ts +3 -2
  29. package/dist/content-loader-store.d.ts.map +1 -1
  30. package/dist/content-loader-store.js +5 -4
  31. package/dist/content-loader-store.js.map +1 -1
  32. package/dist/default-provider.d.ts.map +1 -1
  33. package/dist/default-provider.js +29 -29
  34. package/dist/default-provider.js.map +1 -1
  35. package/dist/directory-view.d.ts +2 -1
  36. package/dist/directory-view.d.ts.map +1 -1
  37. package/dist/directory-view.js +2 -1
  38. package/dist/directory-view.js.map +1 -1
  39. package/dist/hooks/use-content-commands.d.ts +5 -3
  40. package/dist/hooks/use-content-commands.d.ts.map +1 -1
  41. package/dist/hooks/use-content-commands.js.map +1 -1
  42. package/dist/hooks/use-content-document.d.ts +28 -0
  43. package/dist/hooks/use-content-document.d.ts.map +1 -0
  44. package/dist/hooks/use-content-document.js +21 -0
  45. package/dist/hooks/use-content-document.js.map +1 -0
  46. package/dist/hooks/use-content-loader.d.ts +10 -6
  47. package/dist/hooks/use-content-loader.d.ts.map +1 -1
  48. package/dist/hooks/use-content-loader.js +1 -1
  49. package/dist/hooks/use-content-loader.js.map +1 -1
  50. package/dist/hooks/use-view-command-context.d.ts +4 -2
  51. package/dist/hooks/use-view-command-context.d.ts.map +1 -1
  52. package/dist/hooks/use-view-command-context.js +6 -1
  53. package/dist/hooks/use-view-command-context.js.map +1 -1
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/launch.d.ts.map +1 -1
  58. package/dist/launch.js +2 -2
  59. package/dist/launch.js.map +1 -1
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/types.js +3 -33
  62. package/dist/types.js.map +1 -1
  63. package/dist/view.d.ts +2 -1
  64. package/dist/view.d.ts.map +1 -1
  65. package/dist/view.js.map +1 -1
  66. package/package.json +12 -11
  67. package/src/components/subviews/code-subview.tsx +8 -20
  68. package/src/components/subviews/custom-subview.tsx +9 -19
  69. package/src/components/subviews/diff-subview.tsx +35 -43
  70. package/src/components/subviews/image-subview.tsx +12 -11
  71. package/src/components/subviews/markdown-subview.tsx +16 -22
  72. package/src/components/subviews/table-subview.tsx +16 -56
  73. package/src/components/view-state.tsx +2 -1
  74. package/src/content-loader-store.ts +8 -5
  75. package/src/default-provider.ts +34 -32
  76. package/src/directory-view.tsx +4 -2
  77. package/src/hooks/use-content-commands.ts +6 -1
  78. package/src/hooks/use-content-document.ts +54 -0
  79. package/src/hooks/use-content-loader.ts +17 -3
  80. package/src/hooks/use-view-command-context.ts +16 -4
  81. package/src/index.ts +2 -0
  82. package/src/launch.tsx +11 -8
  83. package/src/types.ts +4 -35
  84. package/src/view.tsx +2 -1
  85. package/dist/hooks/index.d.ts +0 -5
  86. package/dist/hooks/index.d.ts.map +0 -1
  87. package/dist/hooks/index.js +0 -4
  88. package/dist/hooks/index.js.map +0 -1
  89. package/src/hooks/index.ts +0 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tooee/view",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Terminal content viewer for markdown, code, text, images, and tables",
5
5
  "keywords": [
6
6
  "cli",
@@ -40,16 +40,16 @@
40
40
  "typecheck": "tsc --noEmit"
41
41
  },
42
42
  "dependencies": {
43
- "@tooee/commands": "0.7.3",
44
- "@tooee/config": "0.7.3",
45
- "@tooee/diff": "0.7.3",
46
- "@tooee/layout": "0.7.3",
47
- "@tooee/marks": "0.7.3",
48
- "@tooee/overlays": "0.7.3",
49
- "@tooee/renderers": "0.7.3",
50
- "@tooee/search": "0.7.3",
51
- "@tooee/shell": "0.7.3",
52
- "@tooee/themes": "0.7.3",
43
+ "@tooee/commands": "0.8.0",
44
+ "@tooee/config": "0.8.0",
45
+ "@tooee/diff": "0.8.0",
46
+ "@tooee/layout": "0.8.0",
47
+ "@tooee/marks": "0.8.0",
48
+ "@tooee/overlays": "0.8.0",
49
+ "@tooee/renderers": "0.8.0",
50
+ "@tooee/search": "0.8.0",
51
+ "@tooee/shell": "0.8.0",
52
+ "@tooee/themes": "0.8.0",
53
53
  "@xstate/store": "^4",
54
54
  "@xstate/store-react": "^2",
55
55
  "marked": "^17.0.4"
@@ -57,6 +57,7 @@
57
57
  "devDependencies": {
58
58
  "@opentui/core": "^0.5.1",
59
59
  "@opentui/react": "^0.5.1",
60
+ "@tooee/test-support": "0.7.3",
60
61
  "@types/bun": "^1.3.10",
61
62
  "@types/react": "^19.2.14",
62
63
  "typescript": "^5.9.3"
@@ -1,11 +1,11 @@
1
1
  import { useMemo } from "react";
2
2
  import { CodeView, sourceLines, sourceLineAdapter } from "@tooee/renderers";
3
3
  import type { SourceLineRow } from "@tooee/renderers";
4
- import { useDocumentController } from "@tooee/shell";
5
4
  import type { CodeContent, TextContent } from "../../types.js";
6
- import { useContentCommands } from "../../hooks/use-content-commands.js";
5
+ import { useContentDocument } from "../../hooks/use-content-document.js";
7
6
  import { ViewScreen } from "../view-screen.js";
8
7
  import type { SubviewProps } from "./types.js";
8
+ import type { ReactNode } from "react";
9
9
 
10
10
  interface CodeSubviewProps extends SubviewProps {
11
11
  content: CodeContent | TextContent;
@@ -16,28 +16,16 @@ export const CodeSubview = function CodeSubview({
16
16
  decorations,
17
17
  actions,
18
18
  ...screen
19
- }: CodeSubviewProps): React.ReactNode {
19
+ }: CodeSubviewProps): ReactNode {
20
20
  const textContent = content.format === "code" ? content.code : content.text;
21
21
  // One navigation row per physical source line; row index is the source line.
22
22
  const lineRows = useMemo(() => sourceLines(textContent), [textContent]);
23
23
 
24
- const { showLineNumbers } = useContentCommands({ content, textContent });
25
-
26
- const document = useDocumentController<SourceLineRow>({
27
- adapter: sourceLineAdapter,
28
- // The controller projects the screen's actions onto menu entries at open time.
29
- contextMenu: actions,
30
- decorations,
31
- multiSelect: true,
32
- rows: lineRows,
33
- });
34
-
35
- const statusItems = useMemo(
36
- () => [
37
- { label: "Format:", value: content.format },
38
- { label: "Lines:", value: String(lineRows.length) },
39
- ],
40
- [content.format, lineRows.length],
24
+ const { document, showLineNumbers, statusItems } = useContentDocument<SourceLineRow>(
25
+ lineRows,
26
+ sourceLineAdapter,
27
+ { actions, content, decorations, textContent },
28
+ { multiSelect: true },
41
29
  );
42
30
 
43
31
  return (
@@ -1,12 +1,12 @@
1
1
  import { useMemo } from "react";
2
2
  import { CodeView, sourceLines, sourceLineAdapter } from "@tooee/renderers";
3
3
  import type { SourceLineRow } from "@tooee/renderers";
4
- import { useDocumentController } from "@tooee/shell";
5
4
  import { getTextContent } from "../../types.js";
6
5
  import type { CustomContent, ContentRenderer } from "../../types.js";
7
- import { useContentCommands } from "../../hooks/use-content-commands.js";
6
+ import { useContentDocument } from "../../hooks/use-content-document.js";
8
7
  import { ViewScreen } from "../view-screen.js";
9
8
  import type { SubviewProps } from "./types.js";
9
+ import type { ReactNode } from "react";
10
10
 
11
11
  interface CustomSubviewProps extends SubviewProps {
12
12
  content: CustomContent;
@@ -19,27 +19,17 @@ export const CustomSubview = function CustomSubview({
19
19
  actions,
20
20
  renderers,
21
21
  ...screen
22
- }: CustomSubviewProps): React.ReactNode {
22
+ }: CustomSubviewProps): ReactNode {
23
23
  const textContent = useMemo(() => getTextContent(content), [content]);
24
24
  // Fallback rows are source lines; row index is the source line.
25
25
  const lineRows = useMemo(() => sourceLines(textContent), [textContent]);
26
26
 
27
- useContentCommands({ content, textContent });
28
-
29
- // Custom content has no action rows of its own, so no context menu is bound.
30
- const document = useDocumentController<SourceLineRow>({
31
- adapter: sourceLineAdapter,
32
- decorations,
33
- multiSelect: true,
34
- rows: lineRows,
35
- });
36
-
37
- const statusItems = useMemo(
38
- () => [
39
- { label: "Format:", value: content.format },
40
- { label: "Lines:", value: String(lineRows.length) },
41
- ],
42
- [content.format, lineRows.length],
27
+ const { document, statusItems } = useContentDocument<SourceLineRow>(
28
+ lineRows,
29
+ sourceLineAdapter,
30
+ { actions, content, decorations, textContent },
31
+ // Custom content has no action rows of its own, so no context menu is bound.
32
+ { contextMenu: false, multiSelect: true },
43
33
  );
44
34
 
45
35
  const customRenderer = renderers?.[content.format];
@@ -5,13 +5,13 @@ import { useCommand } from "@tooee/commands";
5
5
  import { useConfig } from "@tooee/config";
6
6
  import { useOverlay } from "@tooee/overlays";
7
7
  import type { OverlayCloseReason } from "@tooee/overlays";
8
- import { useDocumentController } from "@tooee/shell";
9
8
  import type { DocumentRowAdapter } from "@tooee/shell";
10
9
  import type { DiffContent } from "../../types.js";
11
- import { useContentCommands } from "../../hooks/use-content-commands.js";
10
+ import { useContentDocument } from "../../hooks/use-content-document.js";
12
11
  import { ViewScreen } from "../view-screen.js";
13
12
  import { DiffFilePickerOverlay } from "../diff-file-picker.js";
14
13
  import type { SubviewProps } from "./types.js";
14
+ import type { ReactNode } from "react";
15
15
 
16
16
  interface DiffSubviewProps extends SubviewProps {
17
17
  content: DiffContent;
@@ -41,7 +41,7 @@ export const DiffSubview = function DiffSubview({
41
41
  decorations,
42
42
  actions,
43
43
  ...screen
44
- }: DiffSubviewProps): React.ReactNode {
44
+ }: DiffSubviewProps): ReactNode {
45
45
  const config = useConfig();
46
46
  const overlay = useOverlay();
47
47
  const textContent = content.patch;
@@ -53,17 +53,38 @@ export const DiffSubview = function DiffSubview({
53
53
  const [wrapLines, setWrapLines] = useState(config.view?.wrap ?? false);
54
54
  const [horizontalOffset, setHorizontalOffset] = useState(0);
55
55
 
56
- const { showLineNumbers } = useContentCommands({ content, textContent });
57
-
58
- const document = useDocumentController<DiffRow>({
59
- adapter: DIFF_ROW_ADAPTER,
60
- // The controller projects the screen's actions onto menu entries at open time.
61
- contextMenu: actions,
62
- decorations,
63
- multiSelect: true,
64
- preserveCursorByKey: true,
65
- rows: model.rows,
66
- });
56
+ const { document, showLineNumbers, statusItems } = useContentDocument<DiffRow>(
57
+ model.rows,
58
+ DIFF_ROW_ADAPTER,
59
+ { actions, content, decorations, textContent },
60
+ {
61
+ buildStatusItems: (currentDocument) => {
62
+ const activeRow =
63
+ currentDocument.activeIndex === null
64
+ ? undefined
65
+ : model.rows[currentDocument.activeIndex];
66
+ return [
67
+ { label: "Format:", value: content.format },
68
+ { label: "Files:", value: String(model.files.length) },
69
+ { label: "Changes:", value: `+${model.stats.additions} -${model.stats.deletions}` },
70
+ { label: "Layout:", value: layout },
71
+ ...(activeRow
72
+ ? [
73
+ {
74
+ label: "At:",
75
+ value:
76
+ activeRow.hunkIndex >= 0
77
+ ? `${activeRow.parent.path ?? activeRow.fileId}:${activeRow.hunkIndex + 1}`
78
+ : (activeRow.parent.path ?? activeRow.fileId),
79
+ },
80
+ ]
81
+ : []),
82
+ ];
83
+ },
84
+ multiSelect: true,
85
+ preserveCursorByKey: true,
86
+ },
87
+ );
67
88
 
68
89
  const { activeIndex } = document;
69
90
  // `setCursor` rather than `selectRow`: the latter stands down while a modal
@@ -178,35 +199,6 @@ export const DiffSubview = function DiffSubview({
178
199
  title: "Go to file",
179
200
  });
180
201
 
181
- const activeRow = activeIndex === null ? undefined : model.rows[activeIndex];
182
- const statusItems = useMemo(
183
- () => [
184
- { label: "Format:", value: content.format },
185
- { label: "Files:", value: String(model.files.length) },
186
- { label: "Changes:", value: `+${model.stats.additions} -${model.stats.deletions}` },
187
- { label: "Layout:", value: layout },
188
- ...(activeRow
189
- ? [
190
- {
191
- label: "At:",
192
- value:
193
- activeRow.hunkIndex >= 0
194
- ? `${activeRow.parent.path ?? activeRow.fileId}:${activeRow.hunkIndex + 1}`
195
- : (activeRow.parent.path ?? activeRow.fileId),
196
- },
197
- ]
198
- : []),
199
- ],
200
- [
201
- activeRow,
202
- content.format,
203
- layout,
204
- model.files.length,
205
- model.stats.additions,
206
- model.stats.deletions,
207
- ],
208
- );
209
-
210
202
  return (
211
203
  <ViewScreen
212
204
  content={content}
@@ -1,12 +1,12 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
  import { sourceLineAdapter, sourceLines } from "@tooee/renderers";
3
- import { useDocumentController } from "@tooee/shell";
4
3
  import type { SourceLineRow } from "@tooee/renderers";
5
4
  import type { ImageContent } from "../../types.js";
6
5
  import { getTextContent } from "../../types.js";
7
- import { useContentCommands } from "../../hooks/use-content-commands.js";
6
+ import { useContentDocument } from "../../hooks/use-content-document.js";
8
7
  import { ViewScreen } from "../view-screen.js";
9
8
  import type { SubviewProps } from "./types.js";
9
+ import type { ReactNode } from "react";
10
10
 
11
11
  interface ImageSubviewProps extends SubviewProps {
12
12
  content: ImageContent;
@@ -17,7 +17,7 @@ export const ImageSubview = function ImageSubview({
17
17
  decorations,
18
18
  actions,
19
19
  ...screen
20
- }: ImageSubviewProps): React.ReactNode {
20
+ }: ImageSubviewProps): ReactNode {
21
21
  const textContent = getTextContent(content);
22
22
  const rows = useMemo(() => sourceLines(textContent), [textContent]);
23
23
  const [error, setError] = useState(false);
@@ -26,21 +26,22 @@ export const ImageSubview = function ImageSubview({
26
26
  setError(false);
27
27
  }, [content.src]);
28
28
 
29
- useContentCommands({ content, textContent });
30
- const document = useDocumentController<SourceLineRow>({
31
- adapter: sourceLineAdapter,
32
- contextMenu: actions,
33
- decorations,
34
- multiSelect: false,
29
+ const { document, statusItems } = useContentDocument<SourceLineRow>(
35
30
  rows,
36
- });
31
+ sourceLineAdapter,
32
+ { actions, content, decorations, textContent },
33
+ {
34
+ multiSelect: false,
35
+ statusItems: [{ label: "Format:", value: content.format }],
36
+ },
37
+ );
37
38
 
38
39
  return (
39
40
  <ViewScreen
40
41
  content={content}
41
42
  controller={document}
42
43
  actions={actions}
43
- statusItems={[{ label: "Format:", value: content.format }]}
44
+ statusItems={statusItems}
44
45
  {...screen}
45
46
  >
46
47
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
@@ -3,12 +3,12 @@ import type { TextBufferRenderable } from "@opentui/core";
3
3
  import { MarkdownView, flattenMarkdown, getFlatBlockText } from "@tooee/renderers";
4
4
  import type { CodeBlockRenderer, FlatBlock } from "@tooee/renderers";
5
5
  import { useBuildCommandContext, useCommand } from "@tooee/commands";
6
- import { useDocumentController } from "@tooee/shell";
7
6
  import type { DocumentRowAdapter } from "@tooee/shell";
8
7
  import type { MarkdownContent, MarkdownLinkActivateHandler } from "../../types.js";
9
- import { useContentCommands } from "../../hooks/use-content-commands.js";
8
+ import { useContentDocument } from "../../hooks/use-content-document.js";
10
9
  import { ViewScreen } from "../view-screen.js";
11
10
  import type { SubviewProps } from "./types.js";
11
+ import type { ReactNode } from "react";
12
12
 
13
13
  interface MarkdownSubviewProps extends SubviewProps {
14
14
  content: MarkdownContent;
@@ -36,26 +36,28 @@ export const MarkdownSubview = function MarkdownSubview({
36
36
  decorations,
37
37
  actions,
38
38
  ...screen
39
- }: MarkdownSubviewProps): React.ReactNode {
39
+ }: MarkdownSubviewProps): ReactNode {
40
40
  const textContent = content.markdown;
41
41
  const lineCount = useMemo(() => textContent.split("\n").length, [textContent]);
42
42
  const blocks = useMemo(() => flattenMarkdown(content.markdown), [content.markdown]);
43
43
 
44
- const { showLineNumbers } = useContentCommands({ content, textContent });
44
+ const { document, showLineNumbers, statusItems } = useContentDocument<FlatBlock>(
45
+ blocks,
46
+ MARKDOWN_BLOCK_ADAPTER,
47
+ { actions, content, decorations, textContent },
48
+ {
49
+ multiSelect: true,
50
+ statusItems: [
51
+ { label: "Format:", value: content.format },
52
+ { label: "Lines:", value: String(lineCount) },
53
+ ],
54
+ },
55
+ );
45
56
  const buildCommandContext = useBuildCommandContext();
46
57
  const handleLinkActivate = onLinkActivate
47
- ? (href: string): unknown => onLinkActivate(href, buildCommandContext())
58
+ ? (href: string) => onLinkActivate(href, buildCommandContext())
48
59
  : undefined;
49
60
 
50
- const document = useDocumentController<FlatBlock>({
51
- adapter: MARKDOWN_BLOCK_ADAPTER,
52
- // The controller projects the screen's actions onto menu entries at open time.
53
- contextMenu: actions,
54
- decorations,
55
- multiSelect: true,
56
- rows: blocks,
57
- });
58
-
59
61
  const hScrollableBlocksRef = useRef<Map<number, TextBufferRenderable>>(new Map());
60
62
  const cursorScrollable = () =>
61
63
  document.activeIndex === null
@@ -88,14 +90,6 @@ export const MarkdownSubview = function MarkdownSubview({
88
90
  when: () => cursorScrollable() !== undefined,
89
91
  });
90
92
 
91
- const statusItems = useMemo(
92
- () => [
93
- { label: "Format:", value: content.format },
94
- { label: "Lines:", value: String(lineCount) },
95
- ],
96
- [content.format, lineCount],
97
- );
98
-
99
93
  return (
100
94
  <ViewScreen
101
95
  content={content}
@@ -1,84 +1,44 @@
1
1
  import { useMemo } from "react";
2
- import { Table } from "@tooee/renderers";
2
+ import { Table, formatTableCell } from "@tooee/renderers";
3
3
  import type { TableRow } from "@tooee/renderers";
4
- import { useDocumentController } from "@tooee/shell";
5
4
  import { getTextContent } from "../../types.js";
6
5
  import type { TableContent } from "../../types.js";
7
- import { useContentCommands } from "../../hooks/use-content-commands.js";
6
+ import { useContentDocument } from "../../hooks/use-content-document.js";
8
7
  import { ViewScreen } from "../view-screen.js";
9
8
  import type { SubviewProps } from "./types.js";
9
+ import type { ReactNode } from "react";
10
10
 
11
11
  interface TableSubviewProps extends SubviewProps {
12
12
  content: TableContent;
13
13
  }
14
14
 
15
- const stringifyRowCell = function stringifyRowCell(value: unknown): string {
16
- if (value === null || value === undefined) {
17
- return "";
18
- }
19
- if (typeof value === "string") {
20
- return value;
21
- }
22
- if (typeof value === "number" || typeof value === "boolean") {
23
- return String(value);
24
- }
25
- if (typeof value === "string") {
26
- return value;
27
- }
28
- if (
29
- typeof value === "number" ||
30
- typeof value === "bigint" ||
31
- typeof value === "boolean" ||
32
- typeof value === "symbol"
33
- ) {
34
- return value.toString();
35
- }
36
- if (value === null || value === undefined) {
37
- return String(value);
38
- }
39
- if (typeof value === "function") {
40
- return Function.prototype.toString.call(value);
41
- }
42
- try {
43
- return JSON.stringify(value);
44
- } catch {
45
- return Array.isArray(value) ? value.join(",") : Object.prototype.toString.call(value);
46
- }
47
- };
48
-
49
15
  export const TableSubview = function TableSubview({
50
16
  content,
51
17
  decorations,
52
18
  actions,
53
19
  ...screen
54
- }: TableSubviewProps): React.ReactNode {
20
+ }: TableSubviewProps): ReactNode {
55
21
  const textContent = useMemo(() => getTextContent(content), [content]);
56
- const { showLineNumbers } = useContentCommands({ content, textContent });
57
-
58
22
  const { columns, rows } = content;
59
23
  const adapter = useMemo(
60
24
  () => ({
61
25
  getText: (row: TableRow) =>
62
- columns.map((column) => stringifyRowCell(row[column.key])).join("\t"),
26
+ columns.map((column) => formatTableCell(row[column.key])).join("\t"),
63
27
  }),
64
28
  [columns],
65
29
  );
66
- const document = useDocumentController<TableRow>({
67
- adapter,
68
- // The controller projects the screen's actions onto menu entries at open time.
69
- contextMenu: actions,
70
- decorations,
71
- multiSelect: true,
30
+ const { document, showLineNumbers, statusItems } = useContentDocument<TableRow>(
72
31
  rows,
73
- });
74
-
75
- const statusItems = useMemo(
76
- () => [
77
- { label: "Format:", value: content.format },
78
- { label: "Rows:", value: String(rows.length) },
79
- { label: "Cols:", value: String(columns.length) },
80
- ],
81
- [content.format, rows.length, columns.length],
32
+ adapter,
33
+ { actions, content, decorations, textContent },
34
+ {
35
+ multiSelect: true,
36
+ statusItems: [
37
+ { label: "Format:", value: content.format },
38
+ { label: "Rows:", value: String(rows.length) },
39
+ { label: "Cols:", value: String(columns.length) },
40
+ ],
41
+ },
82
42
  );
83
43
 
84
44
  return (
@@ -1,4 +1,5 @@
1
1
  import { useQuitCommand } from "@tooee/shell";
2
+ import type { ReactNode } from "react";
2
3
 
3
4
  interface ViewStateProps {
4
5
  message: string;
@@ -6,7 +7,7 @@ interface ViewStateProps {
6
7
  }
7
8
 
8
9
  /** A non-document view state that preserves the viewer's normal quit command. */
9
- export const ViewState = function ViewState({ message, color }: ViewStateProps): React.ReactNode {
10
+ export const ViewState = function ViewState({ message, color }: ViewStateProps): ReactNode {
10
11
  useQuitCommand();
11
12
 
12
13
  return (
@@ -2,6 +2,8 @@ import { createStore } from "@xstate/store";
2
2
  import type { MarkSet } from "@tooee/marks";
3
3
  import type { AnyContent, Content, ContentChunk, ContentFormat } from "./types.js";
4
4
 
5
+ type ContentProviderResult = AnyContent | Promise<AnyContent> | AsyncIterable<ContentChunk>;
6
+
5
7
  export type ContentLoaderStatus = "idle" | "loading" | "streaming" | "ready" | "error";
6
8
 
7
9
  export interface ContentLoaderContext {
@@ -30,11 +32,12 @@ export type ContentLoaderEvents = {
30
32
  };
31
33
 
32
34
  export const isAsyncIterable = function isAsyncIterable(
33
- value: unknown,
35
+ value: ContentProviderResult,
34
36
  ): value is AsyncIterable<ContentChunk> {
35
37
  return (
36
38
  value !== null &&
37
39
  value !== undefined &&
40
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- provider result boundary distinguishes the documented async-iterable source from content and promises
38
41
  typeof value === "object" &&
39
42
  Symbol.asyncIterator in value
40
43
  );
@@ -69,11 +72,11 @@ const ensureContentFormat = function ensureContentFormat<F extends ContentFormat
69
72
  title?: string,
70
73
  ): Extract<Content, { format: F }> {
71
74
  if (!current || current.format !== format) {
72
- // Deferred(lint-sweep): add schema-based validation for custom and streamed content formats.
75
+ // SAFETY: createEmptyContent returns the built-in content variant selected by the same format.
73
76
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- format-dependent content is trusted at this provider boundary
74
77
  return createEmptyContent(format, title) as Extract<Content, { format: F }>;
75
78
  }
76
- // Deferred(lint-sweep): add schema-based validation for custom and streamed content formats.
79
+ // SAFETY: The equality guard narrows current to the built-in content variant selected by format.
77
80
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the provider contract supplies the matching format
78
81
  return current as Extract<Content, { format: F }>;
79
82
  };
@@ -117,8 +120,8 @@ export const applyContentChunk = function applyContentChunk(
117
120
  }
118
121
  };
119
122
 
120
- export const normalizeError = function normalizeError(error: unknown): string {
121
- return error instanceof Error ? error.message : String(error);
123
+ export const normalizeError = function normalizeError(cause: unknown): string {
124
+ return cause instanceof Error ? cause.message : String(cause);
122
125
  };
123
126
 
124
127
  export const createContentLoaderStore = function createContentLoaderStore() {
@@ -7,10 +7,12 @@ export interface CreateProviderOptions {
7
7
  renderer?: ContentFormat;
8
8
  }
9
9
 
10
- const detectFormat = function detectFormat(filePath: string): {
10
+ interface DetectedFileFormat {
11
11
  format: ContentFormat;
12
12
  language?: string;
13
- } {
13
+ }
14
+
15
+ const detectFormat = function detectFormat(filePath: string): DetectedFileFormat {
14
16
  const ext = filePath.split(".").pop()?.toLowerCase();
15
17
  if (ext === undefined || ext === "") {
16
18
  return { format: "text" };
@@ -36,36 +38,36 @@ const detectFormat = function detectFormat(filePath: string): {
36
38
  return { format: "markdown" };
37
39
  }
38
40
 
39
- const codeExts: Record<string, string> = {
40
- bash: "bash",
41
- c: "c",
42
- cpp: "cpp",
43
- css: "css",
44
- go: "go",
45
- h: "c",
46
- hpp: "cpp",
47
- html: "html",
48
- java: "java",
49
- js: "javascript",
50
- json: "json",
51
- jsx: "javascript",
52
- kt: "kotlin",
53
- py: "python",
54
- rb: "ruby",
55
- rs: "rust",
56
- sh: "bash",
57
- sql: "sql",
58
- swift: "swift",
59
- toml: "toml",
60
- ts: "typescript",
61
- tsx: "typescript",
62
- yaml: "yaml",
63
- yml: "yaml",
64
- zsh: "zsh",
65
- };
66
-
67
- const language = codeExts[ext];
68
- if (typeof language === "string") {
41
+ const codeExts = new Map<string, string>([
42
+ ["bash", "bash"],
43
+ ["c", "c"],
44
+ ["cpp", "cpp"],
45
+ ["css", "css"],
46
+ ["go", "go"],
47
+ ["h", "c"],
48
+ ["hpp", "cpp"],
49
+ ["html", "html"],
50
+ ["java", "java"],
51
+ ["js", "javascript"],
52
+ ["json", "json"],
53
+ ["jsx", "javascript"],
54
+ ["kt", "kotlin"],
55
+ ["py", "python"],
56
+ ["rb", "ruby"],
57
+ ["rs", "rust"],
58
+ ["sh", "bash"],
59
+ ["sql", "sql"],
60
+ ["swift", "swift"],
61
+ ["toml", "toml"],
62
+ ["ts", "typescript"],
63
+ ["tsx", "typescript"],
64
+ ["yaml", "yaml"],
65
+ ["yml", "yaml"],
66
+ ["zsh", "zsh"],
67
+ ]);
68
+
69
+ const language = codeExts.get(ext);
70
+ if (language !== undefined) {
69
71
  return { format: "code", language };
70
72
  }
71
73
 
@@ -7,6 +7,7 @@ import type { DirectoryEntry } from "./directory-provider.js";
7
7
  import type { AnyContent, ContentProvider } from "./types.js";
8
8
  import { createFileProvider } from "./default-provider.js";
9
9
  import { ViewState } from "./components/view-state.js";
10
+ import type { ReactNode } from "react";
10
11
 
11
12
  const createDirectoryFileProvider = function createDirectoryFileProvider(
12
13
  entry: DirectoryEntry,
@@ -17,7 +18,8 @@ const createDirectoryFileProvider = function createDirectoryFileProvider(
17
18
  return {
18
19
  async load(): Promise<AnyContent> {
19
20
  const result = inner.load();
20
- // Deferred(lint-sweep): add schema-based validation for custom and streamed content formats.
21
+ // SAFETY: createFileProvider owns this provider and its load method returns Content directly
22
+ // or through a Promise; it never returns the ContentProvider async-iterable arm.
21
23
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- file providers return external content at this boundary
22
24
  const content = result instanceof Promise ? await result : (result as AnyContent);
23
25
  return {
@@ -36,7 +38,7 @@ interface DirectoryViewProps {
36
38
  export const DirectoryView = function DirectoryView({
37
39
  dirPath,
38
40
  actions,
39
- }: DirectoryViewProps): React.ReactNode {
41
+ }: DirectoryViewProps): ReactNode {
40
42
  const files = useMemo(() => listDirectoryFiles(dirPath), [dirPath]);
41
43
  const [currentIndex, setCurrentIndex] = useState(0);
42
44