@tooee/view 0.11.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 (107) 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 +3 -1
  10. package/dist/components/subviews/markdown-subview.d.ts.map +1 -1
  11. package/dist/components/subviews/markdown-subview.js +21 -6
  12. package/dist/components/subviews/markdown-subview.js.map +1 -1
  13. package/dist/components/subviews/table-subview.d.ts.map +1 -1
  14. package/dist/components/subviews/table-subview.js +4 -3
  15. package/dist/components/subviews/table-subview.js.map +1 -1
  16. package/dist/components/view-screen.d.ts +3 -1
  17. package/dist/components/view-screen.d.ts.map +1 -1
  18. package/dist/components/view-screen.js +13 -3
  19. package/dist/components/view-screen.js.map +1 -1
  20. package/dist/content-loader-store.d.ts.map +1 -1
  21. package/dist/content-loader-store.js.map +1 -1
  22. package/dist/default-provider.d.ts.map +1 -1
  23. package/dist/default-provider.js.map +1 -1
  24. package/dist/directory-provider.d.ts.map +1 -1
  25. package/dist/directory-provider.js.map +1 -1
  26. package/dist/directory-view.d.ts.map +1 -1
  27. package/dist/directory-view.js +5 -2
  28. package/dist/directory-view.js.map +1 -1
  29. package/dist/folds/fold-decorations.d.ts.map +1 -1
  30. package/dist/folds/fold-decorations.js.map +1 -1
  31. package/dist/folds/fold-model.d.ts +5 -0
  32. package/dist/folds/fold-model.d.ts.map +1 -1
  33. package/dist/folds/fold-model.js +11 -0
  34. package/dist/folds/fold-model.js.map +1 -1
  35. package/dist/folds/markdown-heading-folds.d.ts +3 -0
  36. package/dist/folds/markdown-heading-folds.d.ts.map +1 -1
  37. package/dist/folds/markdown-heading-folds.js +6 -2
  38. package/dist/folds/markdown-heading-folds.js.map +1 -1
  39. package/dist/folds/use-folds.d.ts +30 -5
  40. package/dist/folds/use-folds.d.ts.map +1 -1
  41. package/dist/folds/use-folds.js +65 -75
  42. package/dist/folds/use-folds.js.map +1 -1
  43. package/dist/hooks/use-content-document.d.ts.map +1 -1
  44. package/dist/hooks/use-content-document.js +3 -2
  45. package/dist/hooks/use-content-document.js.map +1 -1
  46. package/dist/hooks/use-content-loader.d.ts.map +1 -1
  47. package/dist/hooks/use-content-loader.js.map +1 -1
  48. package/dist/hooks/use-view-command-context.d.ts.map +1 -1
  49. package/dist/hooks/use-view-command-context.js.map +1 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/launch.d.ts +9 -1
  53. package/dist/launch.d.ts.map +1 -1
  54. package/dist/launch.js.map +1 -1
  55. package/dist/link-handlers.d.ts.map +1 -1
  56. package/dist/link-handlers.js.map +1 -1
  57. package/dist/markdown-links.d.ts.map +1 -1
  58. package/dist/markdown-links.js.map +1 -1
  59. package/dist/outline/markdown-outline.d.ts +24 -0
  60. package/dist/outline/markdown-outline.d.ts.map +1 -0
  61. package/dist/outline/markdown-outline.js +25 -0
  62. package/dist/outline/markdown-outline.js.map +1 -0
  63. package/dist/outline/outline-panel.d.ts +17 -0
  64. package/dist/outline/outline-panel.d.ts.map +1 -0
  65. package/dist/outline/outline-panel.js +106 -0
  66. package/dist/outline/outline-panel.js.map +1 -0
  67. package/dist/outline/use-outline.d.ts +53 -0
  68. package/dist/outline/use-outline.d.ts.map +1 -0
  69. package/dist/outline/use-outline.js +131 -0
  70. package/dist/outline/use-outline.js.map +1 -0
  71. package/dist/standalone-router.d.ts.map +1 -1
  72. package/dist/standalone-router.js +4 -1
  73. package/dist/standalone-router.js.map +1 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js.map +1 -1
  76. package/dist/view.d.ts +3 -1
  77. package/dist/view.d.ts.map +1 -1
  78. package/dist/view.js +2 -2
  79. package/dist/view.js.map +1 -1
  80. package/package.json +14 -13
  81. package/src/components/subviews/diff-subview.tsx +21 -4
  82. package/src/components/subviews/image-subview.tsx +2 -1
  83. package/src/components/subviews/index.ts +6 -0
  84. package/src/components/subviews/markdown-subview.tsx +33 -4
  85. package/src/components/subviews/table-subview.tsx +6 -3
  86. package/src/components/view-screen.tsx +15 -1
  87. package/src/content-loader-store.ts +23 -0
  88. package/src/default-provider.ts +18 -0
  89. package/src/directory-provider.ts +7 -0
  90. package/src/directory-view.tsx +7 -2
  91. package/src/folds/fold-decorations.ts +4 -0
  92. package/src/folds/fold-model.ts +21 -0
  93. package/src/folds/markdown-heading-folds.ts +14 -2
  94. package/src/folds/use-folds.ts +110 -78
  95. package/src/hooks/use-content-document.ts +5 -2
  96. package/src/hooks/use-content-loader.ts +7 -0
  97. package/src/hooks/use-view-command-context.ts +1 -0
  98. package/src/index.ts +17 -0
  99. package/src/launch.tsx +11 -1
  100. package/src/link-handlers.ts +10 -0
  101. package/src/markdown-links.ts +16 -0
  102. package/src/outline/markdown-outline.ts +48 -0
  103. package/src/outline/outline-panel.tsx +184 -0
  104. package/src/outline/use-outline.ts +212 -0
  105. package/src/standalone-router.tsx +24 -2
  106. package/src/types.ts +11 -0
  107. package/src/view.tsx +10 -0
@@ -1,5 +1,7 @@
1
1
  import type { TextBufferRenderable } from "@opentui/core";
2
+ import { useTerminalDimensions } from "@opentui/react";
2
3
  import { useBuildCommandContext, useCommand } from "@tooee/commands";
4
+ import { StatusPriority } from "@tooee/layout";
3
5
  import { MarkdownView, flattenMarkdown, getFlatBlockText } from "@tooee/renderers";
4
6
  import type { CodeBlockRenderer, FlatBlock } from "@tooee/renderers";
5
7
  import type { DocumentRowAdapter } from "@tooee/shell";
@@ -7,8 +9,10 @@ import { useMemo, useRef } from "react";
7
9
  import type { ReactNode } from "react";
8
10
 
9
11
  import { markdownHeadingFoldRanges } from "../../folds/markdown-heading-folds.js";
10
- import { useFoldCommands, useFoldState } from "../../folds/use-folds.js";
12
+ import { useFoldActions, useFoldCommands, useFoldState } from "../../folds/use-folds.js";
11
13
  import { useContentDocument } from "../../hooks/use-content-document.js";
14
+ import { OutlinePanel } from "../../outline/outline-panel.js";
15
+ import { useMarkdownOutline } from "../../outline/use-outline.js";
12
16
  import type { MarkdownContent, MarkdownLinkActivateHandler } from "../../types.js";
13
17
  import { ViewScreen } from "../view-screen.js";
14
18
  import type { SubviewProps } from "./types.js";
@@ -17,6 +21,8 @@ interface MarkdownSubviewProps extends SubviewProps {
17
21
  content: MarkdownContent;
18
22
  codeBlockRenderers?: Record<string, CodeBlockRenderer>;
19
23
  onLinkActivate?: MarkdownLinkActivateHandler;
24
+ /** Open the heading outline beside the document at start. */
25
+ outline?: boolean;
20
26
  }
21
27
 
22
28
  /** Columns moved per h/l press when scrolling a wide block horizontally. */
@@ -33,6 +39,7 @@ const markdownBlockAdapter = function markdownBlockAdapter(
33
39
  blocks: readonly FlatBlock[]
34
40
  ): DocumentRowAdapter<FlatBlock> {
35
41
  const indices = new Map(blocks.map((block, index) => [block, index]));
42
+
36
43
  return {
37
44
  getKey: (block, index) => indices.get(block) ?? index,
38
45
  getSource: (block) => block.source,
@@ -44,6 +51,7 @@ export const MarkdownSubview = function MarkdownSubview({
44
51
  content,
45
52
  codeBlockRenderers,
46
53
  onLinkActivate,
54
+ outline: initialOutline = false,
47
55
  decorations,
48
56
  actions,
49
57
  ...screen
@@ -65,25 +73,43 @@ export const MarkdownSubview = function MarkdownSubview({
65
73
  multiSelect: true,
66
74
  preserveCursorByKey: true,
67
75
  statusItems: [
68
- { label: "Format:", value: content.format },
69
- { label: "Lines:", value: String(lineCount) },
76
+ { label: "Format:", priority: StatusPriority.content, value: content.format },
77
+ { label: "Lines:", priority: StatusPriority.content, value: String(lineCount) },
70
78
  ],
71
79
  }
72
80
  );
73
- useFoldCommands(folds, document.navigation);
81
+
82
+ const foldActions = useFoldActions(folds, document.navigation);
83
+ useFoldCommands(folds, foldActions, document.navigation);
84
+
85
+ // Declared after the controller, so its jump effect runs after the
86
+ // controller has taken the new rows.
87
+ const outline = useMarkdownOutline({
88
+ blocks,
89
+ folds,
90
+ initialOpen: initialOutline,
91
+ navigation: document.navigation,
92
+ revealRow: document.revealRow,
93
+ });
94
+
95
+ const { width: terminalWidth } = useTerminalDimensions();
74
96
  const buildCommandContext = useBuildCommandContext();
97
+
75
98
  const handleLinkActivate = onLinkActivate
76
99
  ? (href: string) => onLinkActivate(href, buildCommandContext())
77
100
  : undefined;
78
101
 
79
102
  const hScrollableBlocksRef = useRef<Map<number, TextBufferRenderable>>(new Map());
103
+
80
104
  const cursorScrollable = () =>
81
105
  document.activeIndex === null
82
106
  ? undefined
83
107
  : hScrollableBlocksRef.current.get(document.activeIndex);
108
+
84
109
  useCommand({
85
110
  handler: () => {
86
111
  const target = cursorScrollable();
112
+
87
113
  if (target) {
88
114
  target.scrollX -= BLOCK_HSCROLL_STEP;
89
115
  }
@@ -97,6 +123,7 @@ export const MarkdownSubview = function MarkdownSubview({
97
123
  useCommand({
98
124
  handler: () => {
99
125
  const target = cursorScrollable();
126
+
100
127
  if (target) {
101
128
  target.scrollX += BLOCK_HSCROLL_STEP;
102
129
  }
@@ -114,6 +141,7 @@ export const MarkdownSubview = function MarkdownSubview({
114
141
  controller={document}
115
142
  actions={actions}
116
143
  statusItems={statusItems}
144
+ aside={<OutlinePanel outline={outline} foldActions={foldActions} />}
117
145
  {...screen}
118
146
  >
119
147
  <MarkdownView
@@ -127,6 +155,7 @@ export const MarkdownSubview = function MarkdownSubview({
127
155
  codeBlockRenderers={codeBlockRenderers}
128
156
  onLinkActivate={handleLinkActivate}
129
157
  imageBasePath={content.imageBasePath}
158
+ width={outline.visible ? terminalWidth - outline.width : undefined}
130
159
  />
131
160
  </ViewScreen>
132
161
  );
@@ -1,3 +1,4 @@
1
+ import { StatusPriority } from "@tooee/layout";
1
2
  import { Table, formatTableCell } from "@tooee/renderers";
2
3
  import type { TableRow } from "@tooee/renderers";
3
4
  import { useMemo } from "react";
@@ -21,6 +22,7 @@ export const TableSubview = function TableSubview({
21
22
  }: TableSubviewProps): ReactNode {
22
23
  const textContent = useMemo(() => getTextContent(content), [content]);
23
24
  const { columns, rows } = content;
25
+
24
26
  const adapter = useMemo(
25
27
  () => ({
26
28
  getText: (row: TableRow) =>
@@ -28,6 +30,7 @@ export const TableSubview = function TableSubview({
28
30
  }),
29
31
  [columns]
30
32
  );
33
+
31
34
  const { document, showLineNumbers, statusItems } = useContentDocument<TableRow>(
32
35
  rows,
33
36
  adapter,
@@ -35,9 +38,9 @@ export const TableSubview = function TableSubview({
35
38
  {
36
39
  multiSelect: true,
37
40
  statusItems: [
38
- { label: "Format:", value: content.format },
39
- { label: "Rows:", value: String(rows.length) },
40
- { label: "Cols:", value: String(columns.length) },
41
+ { label: "Format:", priority: StatusPriority.content, value: content.format },
42
+ { label: "Rows:", priority: StatusPriority.content, value: String(rows.length) },
43
+ { label: "Cols:", priority: StatusPriority.content, value: String(columns.length) },
41
44
  ],
42
45
  }
43
46
  );
@@ -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 { MarkSet } from "@tooee/marks";
4
5
  import { DocumentScreen } from "@tooee/shell";
@@ -21,6 +22,8 @@ export interface ViewScreenProps<T> {
21
22
  setMarkSet: (set: MarkSet) => void;
22
23
  clearMarkNamespace: (namespace: string) => void;
23
24
  clearAllUserMarks: () => void;
25
+ /** Optional side region beside the content, such as the Markdown outline. */
26
+ aside?: ReactNode;
24
27
  children: ReactNode;
25
28
  }
26
29
 
@@ -41,6 +44,7 @@ export const ViewScreen = function ViewScreen<T>({
41
44
  setMarkSet,
42
45
  clearMarkNamespace,
43
46
  clearAllUserMarks,
47
+ aside,
44
48
  children,
45
49
  }: ViewScreenProps<T>): ReactNode {
46
50
  useProvideViewCommandContext({
@@ -57,7 +61,16 @@ export const ViewScreen = function ViewScreen<T>({
57
61
 
58
62
  const items: StatusBarItem[] = [
59
63
  ...(statusItems ?? []),
60
- ...(streaming ? [{ label: "Status:", value: "streaming" }] : []),
64
+ ...(streaming
65
+ ? [
66
+ {
67
+ id: "streaming",
68
+ label: "Status:",
69
+ priority: StatusPriority.critical,
70
+ value: "streaming",
71
+ },
72
+ ]
73
+ : []),
61
74
  ];
62
75
 
63
76
  return (
@@ -71,6 +84,7 @@ export const ViewScreen = function ViewScreen<T>({
71
84
  statusItems={items}
72
85
  actions={actions}
73
86
  context={{ kind: content.format, reload, title: content.title }}
87
+ aside={aside}
74
88
  >
75
89
  {children}
76
90
  </DocumentScreen>
@@ -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);
@@ -70,6 +74,22 @@ export const foldsAt = function foldsAt(
70
74
  .toSorted((left, right) => right.start - left.start || left.end - right.end);
71
75
  };
72
76
 
77
+ /**
78
+ * Keys of the closed ranges that hide `row`: closed ranges whose body
79
+ * (`start + 1 ..= end`) contains it. Opening them all makes `row` visible.
80
+ */
81
+ export const closedFoldsHiding = function closedFoldsHiding(
82
+ ranges: readonly FoldRange[],
83
+ closedKeys: ReadonlySet<Key>,
84
+ row: number
85
+ ): Key[] {
86
+ return ranges
87
+ .values()
88
+ .filter((range) => closedKeys.has(range.key) && range.start < row && row <= range.end)
89
+ .map((range) => range.key)
90
+ .toArray();
91
+ };
92
+
73
93
  /**
74
94
  * The full-array row the cursor should rest on once `view` applies: `row`
75
95
  * itself when it stays visible, otherwise the nearest visible row before it,
@@ -81,5 +101,6 @@ export const visibleAnchorRow = function visibleAnchorRow(view: FoldView, row: n
81
101
  return index;
82
102
  }
83
103
  }
104
+
84
105
  return row;
85
106
  };
@@ -3,13 +3,19 @@ import type { Tokens } from "marked";
3
3
 
4
4
  import type { FoldRange } from "./fold-model.js";
5
5
 
6
- const headingDepth = function headingDepth(block: FlatBlock): number | null {
6
+ /** The Marked heading token of a flattened block, or `null` when the block is not a heading. */
7
+ export const headingToken = function headingToken(block: FlatBlock): Tokens.Heading | null {
7
8
  if (block.token.type !== "heading") {
8
9
  return null;
9
10
  }
11
+
10
12
  // SAFETY: Marked creates a Heading token for the checked "heading" discriminator.
11
13
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Marked's Generic fallback prevents discriminator narrowing
12
- return (block.token as Tokens.Heading).depth;
14
+ return block.token as Tokens.Heading;
15
+ };
16
+
17
+ const headingDepth = function headingDepth(block: FlatBlock): number | null {
18
+ return headingToken(block)?.depth ?? null;
13
19
  };
14
20
 
15
21
  /**
@@ -23,6 +29,7 @@ export const markdownHeadingFoldRanges = function markdownHeadingFoldRanges(
23
29
  ): FoldRange[] {
24
30
  const ranges: FoldRange[] = [];
25
31
  const open: { depth: number; start: number }[] = [];
32
+
26
33
  const close = (start: number, end: number) => {
27
34
  if (end > start) {
28
35
  ranges.push({ end, key: start, start });
@@ -31,17 +38,22 @@ export const markdownHeadingFoldRanges = function markdownHeadingFoldRanges(
31
38
 
32
39
  for (const [index, block] of blocks.entries()) {
33
40
  const depth = headingDepth(block);
41
+
34
42
  if (depth === null) {
35
43
  continue;
36
44
  }
45
+
37
46
  while (open.length > 0 && (open.at(-1)?.depth ?? 0) >= depth) {
38
47
  const finished = open.pop();
48
+
39
49
  if (finished) {
40
50
  close(finished.start, index - 1);
41
51
  }
42
52
  }
53
+
43
54
  open.push({ depth, start: index });
44
55
  }
56
+
45
57
  for (const finished of open) {
46
58
  close(finished.start, blocks.length - 1);
47
59
  }