@tooee/view 0.7.3 → 0.9.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 (106) 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 +4 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/launch.d.ts +5 -0
  59. package/dist/launch.d.ts.map +1 -1
  60. package/dist/launch.js +15 -4
  61. package/dist/launch.js.map +1 -1
  62. package/dist/link-handlers.d.ts +21 -0
  63. package/dist/link-handlers.d.ts.map +1 -0
  64. package/dist/link-handlers.js +35 -0
  65. package/dist/link-handlers.js.map +1 -0
  66. package/dist/markdown-links.d.ts +21 -0
  67. package/dist/markdown-links.d.ts.map +1 -0
  68. package/dist/markdown-links.js +74 -0
  69. package/dist/markdown-links.js.map +1 -0
  70. package/dist/standalone-router.d.ts +11 -0
  71. package/dist/standalone-router.d.ts.map +1 -0
  72. package/dist/standalone-router.js +85 -0
  73. package/dist/standalone-router.js.map +1 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +3 -33
  76. package/dist/types.js.map +1 -1
  77. package/dist/view.d.ts +2 -1
  78. package/dist/view.d.ts.map +1 -1
  79. package/dist/view.js.map +1 -1
  80. package/package.json +14 -11
  81. package/src/components/subviews/code-subview.tsx +8 -20
  82. package/src/components/subviews/custom-subview.tsx +9 -19
  83. package/src/components/subviews/diff-subview.tsx +35 -43
  84. package/src/components/subviews/image-subview.tsx +12 -11
  85. package/src/components/subviews/markdown-subview.tsx +16 -22
  86. package/src/components/subviews/table-subview.tsx +16 -56
  87. package/src/components/view-state.tsx +2 -1
  88. package/src/content-loader-store.ts +8 -5
  89. package/src/default-provider.ts +34 -32
  90. package/src/directory-view.tsx +4 -2
  91. package/src/hooks/use-content-commands.ts +6 -1
  92. package/src/hooks/use-content-document.ts +54 -0
  93. package/src/hooks/use-content-loader.ts +17 -3
  94. package/src/hooks/use-view-command-context.ts +16 -4
  95. package/src/index.ts +4 -0
  96. package/src/launch.tsx +27 -10
  97. package/src/link-handlers.ts +60 -0
  98. package/src/markdown-links.ts +88 -0
  99. package/src/standalone-router.tsx +122 -0
  100. package/src/types.ts +4 -35
  101. package/src/view.tsx +2 -1
  102. package/dist/hooks/index.d.ts +0 -5
  103. package/dist/hooks/index.d.ts.map +0 -1
  104. package/dist/hooks/index.js +0 -4
  105. package/dist/hooks/index.js.map +0 -1
  106. package/src/hooks/index.ts +0 -11
@@ -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
 
@@ -8,6 +8,11 @@ interface UseContentCommandsParams {
8
8
  textContent: string;
9
9
  }
10
10
 
11
+ /** View display state controlled by content-level commands. */
12
+ export interface ContentCommandsResult {
13
+ showLineNumbers: boolean;
14
+ }
15
+
11
16
  /**
12
17
  * The commands a content viewer owns on top of `DocumentScreen`: copying the
13
18
  * whole document and toggling the line-number gutter. Theme, quit, actions and
@@ -16,7 +21,7 @@ interface UseContentCommandsParams {
16
21
  export const useContentCommands = function useContentCommands({
17
22
  content,
18
23
  textContent,
19
- }: UseContentCommandsParams) {
24
+ }: UseContentCommandsParams): ContentCommandsResult {
20
25
  const config = useConfig();
21
26
  const [showLineNumbers, setShowLineNumbers] = useState(config.view?.gutter ?? true);
22
27
 
@@ -0,0 +1,54 @@
1
+ import type { ActionDefinition } from "@tooee/commands";
2
+ import type { StatusBarItem } from "@tooee/layout";
3
+ import type { DecorationLayer } from "@tooee/renderers";
4
+ import { useDocumentController } from "@tooee/shell";
5
+ import type { DocumentController, DocumentRowAdapter } from "@tooee/shell";
6
+ import type { AnyContent } from "../types.js";
7
+ import { useContentCommands } from "./use-content-commands.js";
8
+
9
+ interface ContentDocumentProps {
10
+ actions?: ActionDefinition[];
11
+ content: AnyContent;
12
+ decorations: DecorationLayer[];
13
+ textContent: string;
14
+ }
15
+
16
+ interface ContentDocumentOptions<T> {
17
+ buildStatusItems?: (document: DocumentController<T>) => StatusBarItem[];
18
+ contextMenu?: boolean;
19
+ multiSelect: boolean;
20
+ preserveCursorByKey?: boolean;
21
+ statusItems?: StatusBarItem[];
22
+ }
23
+
24
+ /** Shared document state used by each built-in content subview. */
25
+ export interface ContentDocumentResult<T> {
26
+ document: DocumentController<T>;
27
+ showLineNumbers: boolean;
28
+ statusItems: StatusBarItem[];
29
+ }
30
+
31
+ /** Build the command, controller, and status state shared by content subviews. */
32
+ export const useContentDocument = function useContentDocument<T>(
33
+ rows: readonly T[],
34
+ adapter: DocumentRowAdapter<T>,
35
+ { actions, content, decorations, textContent }: ContentDocumentProps,
36
+ options: ContentDocumentOptions<T>,
37
+ ): ContentDocumentResult<T> {
38
+ const { showLineNumbers } = useContentCommands({ content, textContent });
39
+ const document = useDocumentController<T>({
40
+ adapter,
41
+ contextMenu: options.contextMenu === false ? false : actions,
42
+ decorations,
43
+ multiSelect: options.multiSelect,
44
+ preserveCursorByKey: options.preserveCursorByKey,
45
+ rows,
46
+ });
47
+ const statusItems = options.buildStatusItems?.(document) ??
48
+ options.statusItems ?? [
49
+ { label: "Format:", value: content.format },
50
+ { label: "Lines:", value: String(rows.length) },
51
+ ];
52
+
53
+ return { document, showLineNumbers, statusItems };
54
+ };
@@ -1,6 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo } from "react";
2
2
  import { useSelector } from "@xstate/store-react";
3
- import type { ContentProvider } from "../types.js";
3
+ import type { AnyContent, ContentProvider } from "../types.js";
4
+ import type { MarkSet } from "@tooee/marks";
5
+ import type { ContentLoaderStatus } from "../content-loader-store.js";
4
6
  import {
5
7
  createContentLoaderStore,
6
8
  isAsyncIterable,
@@ -13,7 +15,19 @@ import {
13
15
  selectStreaming,
14
16
  } from "../content-loader-store.js";
15
17
 
16
- export const useContentLoader = function useContentLoader(contentProvider: ContentProvider) {
18
+ /** Reactive state exposed by the content provider loader. */
19
+ export interface ContentLoaderState {
20
+ content: AnyContent | null;
21
+ error: string | null;
22
+ providerMarks: MarkSet[];
23
+ reload: () => void;
24
+ status: ContentLoaderStatus;
25
+ streaming: boolean;
26
+ }
27
+
28
+ export const useContentLoader = function useContentLoader(
29
+ contentProvider: ContentProvider,
30
+ ): ContentLoaderState {
17
31
  const store = useMemo(createContentLoaderStore, []);
18
32
  const loadSeq = useSelector(store, (snapshot) => selectLoadSeq(snapshot.context));
19
33
 
@@ -37,7 +51,7 @@ export const useContentLoader = function useContentLoader(contentProvider: Conte
37
51
  void (async () => {
38
52
  try {
39
53
  while (true) {
40
- // Deferred(lint-sweep): preserve sequential stream consumption for ordered backpressure.
54
+ // preserve sequential stream consumption for ordered backpressure.
41
55
  // oxlint-disable-next-line no-await-in-loop -- each chunk must be consumed in iterator order
42
56
  const result = await iterator.next();
43
57
  if (result.done === true) {
@@ -27,7 +27,7 @@ export interface ViewCommandContext {
27
27
 
28
28
  declare module "@tooee/commands" {
29
29
  interface CommandContext {
30
- view: ViewCommandContext;
30
+ view?: ViewCommandContext;
31
31
  }
32
32
  }
33
33
 
@@ -50,6 +50,18 @@ export interface CreateViewCommandContextOptions {
50
50
 
51
51
  export type ProvideViewCommandContextOptions = CreateViewCommandContextOptions;
52
52
 
53
+ type ViewCommandContextInput =
54
+ | ProvideViewCommandContextOptions
55
+ | (() => ProvideViewCommandContextOptions);
56
+
57
+ /** Resolve the hook's public eager-or-lazy option union at its boundary. */
58
+ const resolveViewCommandContextOptions = function resolveViewCommandContextOptions(
59
+ options: ViewCommandContextInput,
60
+ ): ProvideViewCommandContextOptions {
61
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- public hook boundary distinguishes the documented lazy options callback from eager options
62
+ return typeof options === "function" ? options() : options;
63
+ };
64
+
53
65
  export const createViewCommandContext = function createViewCommandContext({
54
66
  content,
55
67
  format,
@@ -84,9 +96,9 @@ export const createViewCommandContext = function createViewCommandContext({
84
96
  };
85
97
 
86
98
  export const useProvideViewCommandContext = function useProvideViewCommandContext(
87
- options: ProvideViewCommandContextOptions | (() => ProvideViewCommandContextOptions),
88
- ) {
99
+ options: ViewCommandContextInput,
100
+ ): void {
89
101
  useProvideCommandContextKey("view", () =>
90
- createViewCommandContext(typeof options === "function" ? options() : options),
102
+ createViewCommandContext(resolveViewCommandContextOptions(options)),
91
103
  );
92
104
  };
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ export type {
20
20
  ProvideViewCommandContextOptions,
21
21
  ViewCommandContext,
22
22
  } from "./hooks/use-view-command-context.js";
23
+ export type { ContentLoaderState } from "./hooks/use-content-loader.js";
24
+ export type { ContentCommandsResult } from "./hooks/use-content-commands.js";
23
25
  export type {
24
26
  AnyContent,
25
27
  Content,
@@ -44,3 +46,5 @@ export type {
44
46
  export { getTextContent, isBuiltinContent, isCustomContent } from "./types.js";
45
47
  export { CodeBlockChrome } from "@tooee/renderers";
46
48
  export type { CodeBlockRenderer, CodeBlockRendererProps, CodeBlockHScroll } from "@tooee/renderers";
49
+ export { localFileLinkHandler } from "./link-handlers.js";
50
+ export type { LinkHandler, LinkHandlerContext, LinkTarget } from "./link-handlers.js";
package/src/launch.tsx CHANGED
@@ -1,12 +1,19 @@
1
- import { launchCli } from "@tooee/shell";
1
+ import { launchCli, runCliSession } from "@tooee/shell";
2
2
  import type { ActionDefinition } from "@tooee/commands";
3
3
  import type { CodeBlockRenderer } from "@tooee/renderers";
4
- import { View } from "./view.js";
4
+ import { Outlet, RouterProvider } from "@tooee/router";
5
+ import { createStandaloneRouter } from "./standalone-router.js";
6
+ import type { LinkHandler } from "./link-handlers.js";
5
7
  import { DirectoryView } from "./directory-view.js";
6
8
  import type { ContentProvider, ContentRenderer } from "./types.js";
9
+ import type { ReactNode } from "react";
7
10
 
8
11
  export interface ViewLaunchOptions {
9
12
  contentProvider: ContentProvider;
13
+ /** Source file for standalone local Markdown navigation; omitted for stdin. */
14
+ filePath?: string;
15
+ /** Synchronous handlers tried before local file navigation. */
16
+ linkHandlers?: readonly LinkHandler[];
10
17
  actions?: ActionDefinition[];
11
18
  renderers?: Record<string, ContentRenderer>;
12
19
  /**
@@ -23,18 +30,28 @@ export interface DirectoryLaunchOptions {
23
30
  }
24
31
 
25
32
  export const launch = async function launch(options: ViewLaunchOptions): Promise<void> {
26
- await launchCli(
27
- <View
28
- contentProvider={options.contentProvider}
29
- actions={options.actions}
30
- renderers={options.renderers}
31
- codeBlockRenderers={options.codeBlockRenderers}
32
- />,
33
+ const { router } = createStandaloneRouter(options);
34
+ const startup = await router.start();
35
+ if (startup.status !== "committed") {
36
+ throw new Error(`View router startup ${startup.status}`);
37
+ }
38
+ await runCliSession<undefined>(
39
+ (): ReactNode => (
40
+ <RouterProvider router={router}>
41
+ <Outlet />
42
+ </RouterProvider>
43
+ ),
44
+ {
45
+ stdinPolicy: "tty-if-piped",
46
+ stdoutPolicy: "tty-if-redirected",
47
+ },
33
48
  );
34
49
  };
35
50
 
36
51
  export const launchDirectory = async function launchDirectory(
37
52
  options: DirectoryLaunchOptions,
38
53
  ): Promise<void> {
39
- await launchCli(<DirectoryView dirPath={options.dirPath} actions={options.actions} />);
54
+ await launchCli(<DirectoryView dirPath={options.dirPath} actions={options.actions} />, {
55
+ stdoutPolicy: "tty-if-redirected",
56
+ });
40
57
  };
@@ -0,0 +1,60 @@
1
+ import path from "node:path";
2
+ import type { CommandContext } from "@tooee/commands";
3
+ import type { NavigateHandle, RouteDefinition } from "@tooee/router";
4
+ import { resolveMarkdownLink } from "./markdown-links.js";
5
+
6
+ export interface LinkTarget {
7
+ href: string;
8
+ /** Directory that relative targets resolve against. */
9
+ baseDir: string;
10
+ /** Source document for fragment-only links. */
11
+ currentPath?: string;
12
+ }
13
+
14
+ export interface LinkHandlerContext {
15
+ navigate: NavigateHandle;
16
+ documentRoute: RouteDefinition<{ path?: string }>;
17
+ command: CommandContext;
18
+ }
19
+
20
+ /** Decide synchronously: true consumes the link, false tries the next handler. */
21
+ export type LinkHandler = (link: LinkTarget, context: LinkHandlerContext) => boolean;
22
+
23
+ export const localFileLinkHandler: LinkHandler = function localFileLinkHandler(link, context) {
24
+ const result = resolveMarkdownLink(link.href, link.baseDir, link.currentPath);
25
+ if (result.status === "unsupported") {
26
+ return false;
27
+ }
28
+ if (result.status === "file") {
29
+ void context.navigate.push(context.documentRoute, { path: result.path });
30
+ return true;
31
+ }
32
+ const relative = path.relative(link.baseDir, result.path);
33
+ const target =
34
+ relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
35
+ ? result.path
36
+ : relative || ".";
37
+ context.command.toast?.toast({
38
+ level: "warning",
39
+ message: `${result.status === "missing" ? "File not found" : "Not a file"}: ${target}`,
40
+ });
41
+ return true;
42
+ };
43
+
44
+ export const runLinkHandlers = function runLinkHandlers(
45
+ handlers: readonly LinkHandler[],
46
+ link: LinkTarget,
47
+ context: LinkHandlerContext,
48
+ ): boolean {
49
+ for (const handler of handlers) {
50
+ // oxlint-disable-next-line typescript/no-unnecessary-boolean-literal-compare -- preserve exact-true consumption for JavaScript callers too
51
+ if (handler(link, context) === true) {
52
+ return true;
53
+ }
54
+ }
55
+ if (localFileLinkHandler(link, context)) {
56
+ return true;
57
+ }
58
+ context.command.toast?.toast({ level: "warning", message: `Unsupported link: ${link.href}` });
59
+ return true;
60
+ };