@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.
- package/dist/components/subviews/diff-subview.d.ts.map +1 -1
- package/dist/components/subviews/diff-subview.js +16 -4
- package/dist/components/subviews/diff-subview.js.map +1 -1
- package/dist/components/subviews/image-subview.d.ts.map +1 -1
- package/dist/components/subviews/image-subview.js +2 -1
- package/dist/components/subviews/image-subview.js.map +1 -1
- package/dist/components/subviews/index.d.ts.map +1 -1
- package/dist/components/subviews/index.js.map +1 -1
- package/dist/components/subviews/markdown-subview.d.ts +3 -1
- package/dist/components/subviews/markdown-subview.d.ts.map +1 -1
- package/dist/components/subviews/markdown-subview.js +21 -6
- package/dist/components/subviews/markdown-subview.js.map +1 -1
- package/dist/components/subviews/table-subview.d.ts.map +1 -1
- package/dist/components/subviews/table-subview.js +4 -3
- package/dist/components/subviews/table-subview.js.map +1 -1
- package/dist/components/view-screen.d.ts +3 -1
- package/dist/components/view-screen.d.ts.map +1 -1
- package/dist/components/view-screen.js +13 -3
- package/dist/components/view-screen.js.map +1 -1
- package/dist/content-loader-store.d.ts.map +1 -1
- package/dist/content-loader-store.js.map +1 -1
- package/dist/default-provider.d.ts.map +1 -1
- package/dist/default-provider.js.map +1 -1
- package/dist/directory-provider.d.ts.map +1 -1
- package/dist/directory-provider.js.map +1 -1
- package/dist/directory-view.d.ts.map +1 -1
- package/dist/directory-view.js +5 -2
- package/dist/directory-view.js.map +1 -1
- package/dist/folds/fold-decorations.d.ts.map +1 -1
- package/dist/folds/fold-decorations.js.map +1 -1
- package/dist/folds/fold-model.d.ts +5 -0
- package/dist/folds/fold-model.d.ts.map +1 -1
- package/dist/folds/fold-model.js +11 -0
- package/dist/folds/fold-model.js.map +1 -1
- package/dist/folds/markdown-heading-folds.d.ts +3 -0
- package/dist/folds/markdown-heading-folds.d.ts.map +1 -1
- package/dist/folds/markdown-heading-folds.js +6 -2
- package/dist/folds/markdown-heading-folds.js.map +1 -1
- package/dist/folds/use-folds.d.ts +30 -5
- package/dist/folds/use-folds.d.ts.map +1 -1
- package/dist/folds/use-folds.js +65 -75
- package/dist/folds/use-folds.js.map +1 -1
- package/dist/hooks/use-content-document.d.ts.map +1 -1
- package/dist/hooks/use-content-document.js +3 -2
- package/dist/hooks/use-content-document.js.map +1 -1
- package/dist/hooks/use-content-loader.d.ts.map +1 -1
- package/dist/hooks/use-content-loader.js.map +1 -1
- package/dist/hooks/use-view-command-context.d.ts.map +1 -1
- package/dist/hooks/use-view-command-context.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +9 -1
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js.map +1 -1
- package/dist/link-handlers.d.ts.map +1 -1
- package/dist/link-handlers.js.map +1 -1
- package/dist/markdown-links.d.ts.map +1 -1
- package/dist/markdown-links.js.map +1 -1
- package/dist/outline/markdown-outline.d.ts +24 -0
- package/dist/outline/markdown-outline.d.ts.map +1 -0
- package/dist/outline/markdown-outline.js +25 -0
- package/dist/outline/markdown-outline.js.map +1 -0
- package/dist/outline/outline-panel.d.ts +17 -0
- package/dist/outline/outline-panel.d.ts.map +1 -0
- package/dist/outline/outline-panel.js +106 -0
- package/dist/outline/outline-panel.js.map +1 -0
- package/dist/outline/use-outline.d.ts +53 -0
- package/dist/outline/use-outline.d.ts.map +1 -0
- package/dist/outline/use-outline.js +131 -0
- package/dist/outline/use-outline.js.map +1 -0
- package/dist/standalone-router.d.ts.map +1 -1
- package/dist/standalone-router.js +4 -1
- package/dist/standalone-router.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/view.d.ts +3 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +2 -2
- package/dist/view.js.map +1 -1
- package/package.json +14 -13
- package/src/components/subviews/diff-subview.tsx +21 -4
- package/src/components/subviews/image-subview.tsx +2 -1
- package/src/components/subviews/index.ts +6 -0
- package/src/components/subviews/markdown-subview.tsx +33 -4
- package/src/components/subviews/table-subview.tsx +6 -3
- package/src/components/view-screen.tsx +15 -1
- package/src/content-loader-store.ts +23 -0
- package/src/default-provider.ts +18 -0
- package/src/directory-provider.ts +7 -0
- package/src/directory-view.tsx +7 -2
- package/src/folds/fold-decorations.ts +4 -0
- package/src/folds/fold-model.ts +21 -0
- package/src/folds/markdown-heading-folds.ts +14 -2
- package/src/folds/use-folds.ts +110 -78
- package/src/hooks/use-content-document.ts +5 -2
- package/src/hooks/use-content-loader.ts +7 -0
- package/src/hooks/use-view-command-context.ts +1 -0
- package/src/index.ts +17 -0
- package/src/launch.tsx +11 -1
- package/src/link-handlers.ts +10 -0
- package/src/markdown-links.ts +16 -0
- package/src/outline/markdown-outline.ts +48 -0
- package/src/outline/outline-panel.tsx +184 -0
- package/src/outline/use-outline.ts +212 -0
- package/src/standalone-router.tsx +24 -2
- package/src/types.ts +11 -0
- 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
|
-
|
|
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
|
|
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;
|
package/src/default-provider.ts
CHANGED
|
@@ -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
|
|
package/src/directory-view.tsx
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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
|
}
|
package/src/folds/fold-model.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|