@tooee/view 0.12.0 → 0.13.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 (92) 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.map +1 -1
  34. package/dist/folds/use-folds.d.ts.map +1 -1
  35. package/dist/folds/use-folds.js.map +1 -1
  36. package/dist/hooks/use-content-document.d.ts.map +1 -1
  37. package/dist/hooks/use-content-document.js +3 -2
  38. package/dist/hooks/use-content-document.js.map +1 -1
  39. package/dist/hooks/use-content-loader.d.ts.map +1 -1
  40. package/dist/hooks/use-content-loader.js.map +1 -1
  41. package/dist/hooks/use-view-command-context.d.ts.map +1 -1
  42. package/dist/hooks/use-view-command-context.js.map +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/launch.d.ts +7 -1
  46. package/dist/launch.d.ts.map +1 -1
  47. package/dist/launch.js.map +1 -1
  48. package/dist/link-handlers.d.ts.map +1 -1
  49. package/dist/link-handlers.js.map +1 -1
  50. package/dist/markdown-links.d.ts.map +1 -1
  51. package/dist/markdown-links.js.map +1 -1
  52. package/dist/outline/markdown-outline.d.ts.map +1 -1
  53. package/dist/outline/markdown-outline.js.map +1 -1
  54. package/dist/outline/outline-panel.d.ts.map +1 -1
  55. package/dist/outline/outline-panel.js.map +1 -1
  56. package/dist/outline/use-outline.d.ts.map +1 -1
  57. package/dist/outline/use-outline.js.map +1 -1
  58. package/dist/standalone-router.d.ts.map +1 -1
  59. package/dist/standalone-router.js +3 -0
  60. package/dist/standalone-router.js.map +1 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/types.js.map +1 -1
  63. package/dist/view.d.ts.map +1 -1
  64. package/dist/view.js.map +1 -1
  65. package/package.json +14 -14
  66. package/src/components/subviews/diff-subview.tsx +21 -4
  67. package/src/components/subviews/image-subview.tsx +2 -1
  68. package/src/components/subviews/index.ts +6 -0
  69. package/src/components/subviews/markdown-subview.tsx +12 -2
  70. package/src/components/subviews/table-subview.tsx +6 -3
  71. package/src/components/view-screen.tsx +11 -1
  72. package/src/content-loader-store.ts +23 -0
  73. package/src/default-provider.ts +18 -0
  74. package/src/directory-provider.ts +7 -0
  75. package/src/directory-view.tsx +7 -2
  76. package/src/folds/fold-decorations.ts +4 -0
  77. package/src/folds/fold-model.ts +8 -1
  78. package/src/folds/markdown-heading-folds.ts +7 -0
  79. package/src/folds/use-folds.ts +18 -0
  80. package/src/hooks/use-content-document.ts +5 -2
  81. package/src/hooks/use-content-loader.ts +7 -0
  82. package/src/hooks/use-view-command-context.ts +1 -0
  83. package/src/index.ts +17 -0
  84. package/src/launch.tsx +9 -1
  85. package/src/link-handlers.ts +10 -0
  86. package/src/markdown-links.ts +16 -0
  87. package/src/outline/markdown-outline.ts +3 -0
  88. package/src/outline/outline-panel.tsx +13 -0
  89. package/src/outline/use-outline.ts +24 -0
  90. package/src/standalone-router.tsx +23 -2
  91. package/src/types.ts +11 -0
  92. package/src/view.tsx +6 -0
@@ -19,39 +19,53 @@ export const resolveMarkdownLink = function resolveMarkdownLink(
19
19
  ): LinkResolution {
20
20
  try {
21
21
  const decoded = decodeURIComponent(href);
22
+
22
23
  if (/[\p{Cc}\\]/u.test(decoded)) {
23
24
  return { status: "unsupported" };
24
25
  }
26
+
25
27
  const target = href.trim();
28
+
26
29
  if (!target || target.startsWith("//")) {
27
30
  return { status: "unsupported" };
28
31
  }
32
+
29
33
  const [rawPath] = target.split("#", 1);
34
+
30
35
  if (rawPath.includes("?")) {
31
36
  return { status: "unsupported" };
32
37
  }
38
+
33
39
  let path: string;
40
+
34
41
  if (/^[a-zA-Z][a-zA-Z\d+.-]*:/u.test(rawPath)) {
35
42
  // Check the authority before URL normalizes localhost into an empty host.
36
43
  if (!/^file:\/\/\//iu.test(rawPath)) {
37
44
  return { status: "unsupported" };
38
45
  }
46
+
39
47
  const url = new URL(rawPath);
48
+
40
49
  if (url.hostname || url.search) {
41
50
  return { status: "unsupported" };
42
51
  }
52
+
43
53
  path = fileURLToPath(url);
44
54
  } else {
45
55
  path = rawPath
46
56
  ? nodePath.resolve(baseDir, decodeURIComponent(rawPath))
47
57
  : (currentPath ?? baseDir);
48
58
  }
59
+
49
60
  const absolute = nodePath.resolve(path);
61
+
50
62
  try {
51
63
  const stat = statSync(absolute);
64
+
52
65
  if (stat.isFile()) {
53
66
  return { path: absolute, status: "file" };
54
67
  }
68
+
55
69
  return stat.isDirectory()
56
70
  ? { path: absolute, status: "directory" }
57
71
  : { status: "unsupported" };
@@ -82,8 +96,10 @@ const collect = (tokens: Token[], links: MarkdownLink[]): void => {
82
96
  }
83
97
  }
84
98
  };
99
+
85
100
  export const markdownLinks = function markdownLinks(line: string): MarkdownLink[] {
86
101
  const links: MarkdownLink[] = [];
87
102
  collect(Lexer.lexInline(line), links);
103
+
88
104
  return links;
89
105
  };
@@ -25,11 +25,14 @@ export const markdownOutline = function markdownOutline(
25
25
  ): OutlineEntry[] {
26
26
  const headings = blocks.flatMap((block, row) => {
27
27
  const heading = headingToken(block);
28
+
28
29
  return heading === null
29
30
  ? []
30
31
  : [{ depth: heading.depth, row, text: getPlainText(heading.tokens).trim() }];
31
32
  });
33
+
32
34
  const minDepth = Math.min(...headings.map((heading) => heading.depth));
35
+
33
36
  return headings.map((heading) => ({ ...heading, level: heading.depth - minDepth }));
34
37
  };
35
38
 
@@ -12,7 +12,9 @@ import type { OutlineEntry } from "./markdown-outline.js";
12
12
  import type { OutlineState } from "./use-outline.js";
13
13
 
14
14
  const OUTLINE_PANEL_ID = "outline";
15
+
15
16
  const INDENT = " ";
17
+
16
18
  /** Columns the panel border and the list padding take from the panel width. */
17
19
  const OUTLINE_CHROME_WIDTH = 4;
18
20
 
@@ -29,6 +31,7 @@ export const outlineLabel = function outlineLabel(
29
31
  const label = `${INDENT.repeat(entry.level)}${current ? "▸ " : " "}${entry.text}`;
30
32
  const room = Math.max(1, width - suffix.length);
31
33
  const cut = label.length > room ? `${label.slice(0, Math.max(0, room - 1))}…` : label;
34
+
32
35
  return `${cut}${suffix}`;
33
36
  };
34
37
 
@@ -49,18 +52,23 @@ const OutlineCommands = function OutlineCommands({
49
52
  const next = (): void => {
50
53
  outline.move(1);
51
54
  };
55
+
52
56
  const previous = (): void => {
53
57
  outline.move(-1);
54
58
  };
59
+
55
60
  const first = (): void => {
56
61
  outline.select(0);
57
62
  };
63
+
58
64
  const last = (): void => {
59
65
  outline.select(outline.entries.length - 1);
60
66
  };
67
+
61
68
  const jump = (): void => {
62
69
  outline.jump(outline.selectedIndex);
63
70
  };
71
+
64
72
  const commands: [hotkey: string, id: string, title: string, run: () => void][] = [
65
73
  ["j", "outline.down", "Next heading", next],
66
74
  ["down", "outline.down-arrow", "Next heading", next],
@@ -72,6 +80,7 @@ const OutlineCommands = function OutlineCommands({
72
80
  ["escape", "outline.focus-content", "Return to document", outline.focusContent],
73
81
  ["g o", "outline.close", "Close outline", outline.close],
74
82
  ];
83
+
75
84
  const selectedRow = (): number | undefined => outline.entries[outline.selectedIndex]?.row;
76
85
  useActions([
77
86
  ...commands.map(([hotkey, id, title, run]): ActionDefinition => ({
@@ -84,6 +93,7 @@ const OutlineCommands = function OutlineCommands({
84
93
  })),
85
94
  ...foldActionDefinitions(foldActions, selectedRow, "outline.fold", "No fold at heading"),
86
95
  ]);
96
+
87
97
  return null;
88
98
  };
89
99
 
@@ -113,6 +123,7 @@ const OutlineList = function OutlineList({ outline }: { outline: OutlineState })
113
123
  {outline.entries.map((entry, index): ReactNode => {
114
124
  const current = index === outline.currentIndex;
115
125
  const highlighted = outline.focused && index === outline.selectedIndex;
126
+
116
127
  return (
117
128
  <box
118
129
  key={entry.row}
@@ -152,10 +163,12 @@ export const OutlinePanel = function OutlinePanel({
152
163
  if (!outline.visible) {
153
164
  return null;
154
165
  }
166
+
155
167
  // The only panel asks for activation on a mouse-down inside it.
156
168
  const handleActivePanelChange = (): void => {
157
169
  outline.focus();
158
170
  };
171
+
159
172
  return (
160
173
  <PanelGroup
161
174
  activePanelId={outline.focused ? OUTLINE_PANEL_ID : null}
@@ -11,10 +11,13 @@ import type { OutlineEntry } from "./markdown-outline.js";
11
11
 
12
12
  /** Outline width in columns, border included. */
13
13
  const OUTLINE_WIDTH = 32;
14
+
14
15
  /** The outline never takes more than this share of the terminal width. */
15
16
  const OUTLINE_MAX_SHARE = 0.4;
17
+
16
18
  /** Below this terminal width the outline is not shown. */
17
19
  export const OUTLINE_MIN_TERMINAL_WIDTH = 60;
20
+
18
21
  /**
19
22
  * Lines of the document kept above a heading after a jump. The heading lands
20
23
  * near the top of the view, with a little of the text before it for context.
@@ -26,6 +29,7 @@ export const outlineWidth = function outlineWidth(terminalWidth: number): number
26
29
  if (terminalWidth < OUTLINE_MIN_TERMINAL_WIDTH) {
27
30
  return 0;
28
31
  }
32
+
29
33
  return Math.min(OUTLINE_WIDTH, Math.floor(terminalWidth * OUTLINE_MAX_SHARE));
30
34
  };
31
35
 
@@ -85,6 +89,7 @@ export const useMarkdownOutline = function useMarkdownOutline({
85
89
  const { closedKeys, ranges, setClosedKeys, view } = folds;
86
90
  const { cursor, setCursor } = navigation;
87
91
  const visible = open && width > 0;
92
+
88
93
  const moveTo = useCallback(
89
94
  (index: number) => {
90
95
  setCursor(index);
@@ -92,10 +97,12 @@ export const useMarkdownOutline = function useMarkdownOutline({
92
97
  },
93
98
  [setCursor, revealRow]
94
99
  );
100
+
95
101
  const focused = visible && focusRequested;
96
102
  const sourceRow = cursor === null ? undefined : view.visibleToSource[cursor];
97
103
  const currentIndex = sourceRow === undefined ? -1 : outlineIndexAt(entries, sourceRow);
98
104
  const selectedIndex = focused ? Math.min(selected, entries.length - 1) : currentIndex;
105
+
99
106
  const closedRows = useMemo(
100
107
  () => new Set(ranges.filter((range) => closedKeys.has(range.key)).map((range) => range.start)),
101
108
  [ranges, closedKeys]
@@ -108,6 +115,7 @@ export const useMarkdownOutline = function useMarkdownOutline({
108
115
  useEffect(() => {
109
116
  const row = pendingRowRef.current;
110
117
  const index = row === null ? -1 : (view.sourceToVisible[row] ?? -1);
118
+
111
119
  if (index !== -1) {
112
120
  pendingRowRef.current = null;
113
121
  moveTo(index);
@@ -118,34 +126,46 @@ export const useMarkdownOutline = function useMarkdownOutline({
118
126
  setSelected(Math.max(currentIndex, 0));
119
127
  setFocusRequested(true);
120
128
  };
129
+
121
130
  const focusContent = () => {
122
131
  setFocusRequested(false);
123
132
  };
133
+
124
134
  const close = () => {
125
135
  setOpen(false);
126
136
  setFocusRequested(false);
127
137
  };
138
+
128
139
  const select = (index: number) => {
129
140
  setSelected(Math.max(0, Math.min(index, entries.length - 1)));
130
141
  };
142
+
131
143
  const move = (delta: number) => {
132
144
  select(selectedIndex + delta);
133
145
  };
146
+
134
147
  const jump = (index: number) => {
135
148
  const entry = entries[index];
136
149
  setFocusRequested(false);
150
+
137
151
  if (entry === undefined) {
138
152
  return;
139
153
  }
154
+
140
155
  const hiding = closedFoldsHiding(ranges, closedKeys, entry.row);
156
+
141
157
  if (hiding.length === 0) {
142
158
  moveTo(view.sourceToVisible[entry.row] ?? 0);
159
+
143
160
  return;
144
161
  }
162
+
145
163
  const next = new Set(closedKeys);
164
+
146
165
  for (const key of hiding) {
147
166
  next.delete(key);
148
167
  }
168
+
149
169
  pendingRowRef.current = entry.row;
150
170
  setClosedKeys(next);
151
171
  };
@@ -155,12 +175,16 @@ export const useMarkdownOutline = function useMarkdownOutline({
155
175
  handler: (ctx) => {
156
176
  if (width === 0) {
157
177
  ctx.toast?.toast({ level: "info", message: "The outline needs a wider terminal" });
178
+
158
179
  return;
159
180
  }
181
+
160
182
  if (open && focused) {
161
183
  close();
184
+
162
185
  return;
163
186
  }
187
+
164
188
  setOpen(true);
165
189
  focus();
166
190
  },
@@ -2,7 +2,6 @@ import path from "node:path";
2
2
 
3
3
  import { useChooseDialog } from "@tooee/choose";
4
4
  import { useCommand } from "@tooee/commands";
5
- import type { CommandContext } from "@tooee/commands";
6
5
  import {
7
6
  createRoute,
8
7
  createRouter,
@@ -22,6 +21,7 @@ import { runLinkHandlers } from "./link-handlers.js";
22
21
  import { markdownLinks } from "./markdown-links.js";
23
22
  import type { MarkdownLink } from "./markdown-links.js";
24
23
  import { getTextContent } from "./types.js";
24
+ import type { MarkdownLinkActivateHandler } from "./types.js";
25
25
  import { View } from "./view.js";
26
26
 
27
27
  const documentParams: Codec<{ path?: string }> = {
@@ -30,12 +30,15 @@ const documentParams: Codec<{ path?: string }> = {
30
30
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
31
31
  throw new TypeError("Expected document params");
32
32
  }
33
+
33
34
  if (!("path" in value) || value.path === undefined) {
34
35
  return {};
35
36
  }
37
+
36
38
  if (typeof value.path !== "string" || !path.isAbsolute(value.path)) {
37
39
  throw new TypeError("Expected an absolute document path");
38
40
  }
41
+
39
42
  /* oxlint-enable anti-slop/no-runtime-typeof */
40
43
  return { path: value.path };
41
44
  },
@@ -55,32 +58,45 @@ export const createStandaloneRouter = function createStandaloneRouter(
55
58
  const navigate = useNavigate();
56
59
  const { stack } = useRouter();
57
60
  const chooseLink = useChooseDialog<MarkdownLink>();
61
+
58
62
  const provider = useMemo(
59
63
  () =>
60
64
  documentPath === undefined ? options.contentProvider : createFileProvider(documentPath),
61
65
  [documentPath]
62
66
  );
63
- const activate = (href: string, command: CommandContext): boolean => {
67
+
68
+ const activate: MarkdownLinkActivateHandler = (href, command) => {
69
+ if (options.onMarkdownLinkActivate) {
70
+ return options.onMarkdownLinkActivate(href, command);
71
+ }
72
+
64
73
  if (options.filePath === undefined) {
65
74
  command.toast?.toast({ level: "info", message: "Links need a source file" });
75
+
66
76
  return true;
67
77
  }
78
+
68
79
  const currentPath = path.resolve(documentPath ?? options.filePath);
80
+
69
81
  return runLinkHandlers(
70
82
  options.linkHandlers ?? [],
71
83
  { baseDir: path.dirname(currentPath), currentPath, href },
72
84
  { command, documentRoute, navigate }
73
85
  );
74
86
  };
87
+
75
88
  useCommand({
76
89
  handler: async (context) => {
77
90
  // Source positions are zero-based, unlike the displayed line numbers.
78
91
  const line = context.document?.activeAnchor?.source?.primary?.start.line;
79
92
  const content = context.view?.content;
93
+
80
94
  if (line === undefined || content === undefined) {
81
95
  return;
82
96
  }
97
+
83
98
  const links = markdownLinks(getTextContent(content).split("\n")[line] ?? "");
99
+
84
100
  // One link follows directly; several open a chooser so none is silently preferred.
85
101
  const link =
86
102
  links.length > 1
@@ -90,6 +106,7 @@ export const createStandaloneRouter = function createStandaloneRouter(
90
106
  toItem: (item) => ({ description: item.href, text: item.text }),
91
107
  })
92
108
  : (links[0] ?? null);
109
+
93
110
  if (link !== null) {
94
111
  activate(link.href, context);
95
112
  }
@@ -100,6 +117,7 @@ export const createStandaloneRouter = function createStandaloneRouter(
100
117
  title: "Follow Markdown link",
101
118
  when: (context) => context.view?.format === "markdown",
102
119
  });
120
+
103
121
  return (
104
122
  <View
105
123
  key={`${stack.length}:${documentPath ?? ""}`}
@@ -112,14 +130,17 @@ export const createStandaloneRouter = function createStandaloneRouter(
112
130
  />
113
131
  );
114
132
  };
133
+
115
134
  const documentRoute = createRoute({
116
135
  component: DocumentScreen,
117
136
  id: "view.document",
118
137
  params: documentParams,
119
138
  });
139
+
120
140
  const router = createRouter({
121
141
  initial: { routeId: documentRoute.id },
122
142
  routes: [documentRoute],
123
143
  });
144
+
124
145
  return { documentRoute, router };
125
146
  };
package/src/types.ts CHANGED
@@ -137,7 +137,9 @@ export interface ContentProvider {
137
137
  // === Compat aliases ===
138
138
 
139
139
  export type ViewContent = Content;
140
+
140
141
  export type ViewContentProvider = ContentProvider;
142
+
141
143
  export type { ColumnDef, TableRow } from "@tooee/renderers";
142
144
 
143
145
  // === Utilities ===
@@ -167,29 +169,38 @@ export const getTextContent = function getTextContent(content: AnyContent): stri
167
169
  case "markdown": {
168
170
  return content.markdown;
169
171
  }
172
+
170
173
  case "code": {
171
174
  return content.code;
172
175
  }
176
+
173
177
  case "diff": {
174
178
  return content.patch;
175
179
  }
180
+
176
181
  case "text": {
177
182
  return content.text;
178
183
  }
184
+
179
185
  case "image": {
180
186
  // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ImageSource is OpenTUI's public string-or-binary union; only its string and URL variants have text content
181
187
  if (typeof content.src === "string") {
182
188
  return content.src;
183
189
  }
190
+
184
191
  return content.src instanceof URL ? content.src.href : "";
185
192
  }
193
+
186
194
  case "table": {
187
195
  const headers = content.columns.map((column) => column.header ?? column.key);
196
+
188
197
  const rowLines = content.rows.map((row) =>
189
198
  content.columns.map((column) => formatTableCell(row[column.key])).join("\t")
190
199
  );
200
+
191
201
  return [headers.join("\t"), ...rowLines].join("\n");
192
202
  }
203
+
193
204
  default: {
194
205
  return "";
195
206
  }
package/src/view.tsx CHANGED
@@ -50,6 +50,7 @@ export const View = function View({
50
50
  const setMarkSet = useCallback((set: MarkSet) => {
51
51
  setUserMarks((prev) => {
52
52
  const filtered = prev.filter((s) => s.namespace !== set.namespace);
53
+
53
54
  return [...filtered, set];
54
55
  });
55
56
  }, []);
@@ -109,19 +110,24 @@ export const View = function View({
109
110
  />
110
111
  );
111
112
  }
113
+
112
114
  case "code":
113
115
  case "text": {
114
116
  return <CodeSubview content={content} {...shared} />;
115
117
  }
118
+
116
119
  case "image": {
117
120
  return <ImageSubview content={content} {...shared} />;
118
121
  }
122
+
119
123
  case "table": {
120
124
  return <TableSubview content={content} {...shared} />;
121
125
  }
126
+
122
127
  case "diff": {
123
128
  return <DiffSubview content={content} {...shared} />;
124
129
  }
130
+
125
131
  default: {
126
132
  return null;
127
133
  }