@kabmo-labs/kabmo 0.8.0 → 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.
- package/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +314 -314
- package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +9 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +47 -67
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +19 -24
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
- package/src/client/hooks/ui/updates/shell-download.ts +55 -14
- package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +20 -5
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +37 -8
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/fs/worktree/create.ts +2 -3
- package/src/server/lib/fs/worktree/list.ts +13 -0
- package/src/server/lib/fs/worktree/remove.ts +3 -3
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.ts +94 -3
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +33 -2
- package/src/server/lib/updates/shell-pages.ts +17 -5
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +10 -27
- package/src/server/lib/updates/shell-release.ts +107 -111
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +29 -7
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/list.ts +11 -1
- package/src/server/routes/worktrees/routes.test.ts +17 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +4 -4
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-gtbpewr7.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
EDITOR_MIN_FONT_SIZE,
|
|
22
22
|
} from '@/shared/lib/code/editor/typography';
|
|
23
23
|
import { useEditorFontStack } from '@/client/hooks/editor/font';
|
|
24
|
+
import { useEditorTheme } from '@/client/hooks/editor/theme';
|
|
24
25
|
import { MOBILE_SCROLL_POSITIONS_KEY } from '@/shared/lib/workspace/scroll-positions';
|
|
25
26
|
import { ViewerSurface, hasViewerSurface } from '@/client/components/workspace/editor/viewers/dispatch';
|
|
26
27
|
import { BINARY_VIEWERS, RENDERED_VIEWERS, VIEWER_LABELS, viewerKindFor } from '@/shared/lib/fs/document-kind';
|
|
@@ -49,6 +50,7 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
49
50
|
const [fontSize, setFontSize] = useState(EDITOR_DEFAULT_FONT_SIZE);
|
|
50
51
|
const [wordWrap, setWordWrap] = useState(true);
|
|
51
52
|
const editorFontFamily = useEditorFontStack();
|
|
53
|
+
const editorTheme = useEditorTheme();
|
|
52
54
|
|
|
53
55
|
const isMd = viewerKind === 'markdown';
|
|
54
56
|
// A byte viewer (image, PDF, DOCX, workbook) owns the whole surface and has
|
|
@@ -76,6 +78,12 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
76
78
|
missing,
|
|
77
79
|
});
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The code surface's scroller, which belongs to CodeMirror and appears only
|
|
83
|
+
* once its view mounts.
|
|
84
|
+
*/
|
|
85
|
+
const [codeScroller, setCodeScroller] = useState<HTMLElement | null>(null);
|
|
86
|
+
|
|
79
87
|
// Closing and reopening the same file is a remount, so the offset is restored
|
|
80
88
|
// from the session's own state — the phone shows one file at a time, and its
|
|
81
89
|
// key is that file's path (a rendered view keeps its own offset, as on the
|
|
@@ -88,6 +96,9 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
88
96
|
// and the desktop drops entries when a tab closes — against ids that mean
|
|
89
97
|
// nothing here.
|
|
90
98
|
slot: MOBILE_SCROLL_POSITIONS_KEY,
|
|
99
|
+
// The code surface's scroller belongs to CodeMirror and appears only once
|
|
100
|
+
// its view mounts; the markdown preview owns its own container.
|
|
101
|
+
element: isMd && isPreview ? undefined : codeScroller,
|
|
91
102
|
});
|
|
92
103
|
|
|
93
104
|
// A search row on the phone opens this full-screen editor, so the hand-off
|
|
@@ -135,9 +146,15 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
135
146
|
// The sheet's own list, from the LIVE buffer — the same derivation the
|
|
136
147
|
// desktop section uses, so the two cannot disagree about what a state means.
|
|
137
148
|
// A byte viewer has no text buffer to scan, which is its own sentence.
|
|
138
|
-
const {
|
|
149
|
+
const {
|
|
150
|
+
symbols: outlineSymbols,
|
|
151
|
+
total: outlineTotal,
|
|
152
|
+
truncated: outlineTruncated,
|
|
153
|
+
notice: outlineNotice,
|
|
154
|
+
} = useOutlineView({
|
|
139
155
|
content,
|
|
140
156
|
language: lang,
|
|
157
|
+
path: file.path ?? null,
|
|
141
158
|
state: byteViewer ? 'not-a-document' : editor.loadError ? 'error' : editor.loaded ? 'ready' : 'loading',
|
|
142
159
|
note: byteViewer
|
|
143
160
|
? `${VIEWER_LABELS[viewerKind]} files have no text outline.`
|
|
@@ -148,6 +165,7 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
148
165
|
<div
|
|
149
166
|
className="fixed inset-x-0 top-0 h-dvh z-[60] bg-paper flex flex-col font-mono text-xs select-none"
|
|
150
167
|
{...drop.dropProps}
|
|
168
|
+
{...drop.captureDropProps}
|
|
151
169
|
>
|
|
152
170
|
<EditorDropOverlay visible={drop.isDragging} />
|
|
153
171
|
|
|
@@ -208,7 +226,9 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
208
226
|
content={editor.content}
|
|
209
227
|
revision={editor.revision}
|
|
210
228
|
/>
|
|
211
|
-
) : (
|
|
229
|
+
) : editor.isLoading || editor.unsupported || editor.loadError || (isMd && isPreview) ? (
|
|
230
|
+
// The states that draw their own scrolling content — and the markdown
|
|
231
|
+
// preview, which is a document, not an editor — share one scroller.
|
|
212
232
|
<div
|
|
213
233
|
ref={scroll.ref}
|
|
214
234
|
onScroll={scroll.onScroll}
|
|
@@ -233,7 +253,7 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
233
253
|
<span className="text-xs text-error font-sans">Could not read {file.name}</span>
|
|
234
254
|
<span className="text-[11px] text-ink/50 font-mono break-all">{editor.loadError}</span>
|
|
235
255
|
</div>
|
|
236
|
-
) :
|
|
256
|
+
) : (
|
|
237
257
|
/* Markdown Preview Mode — styled by `.prose-content` (the chat
|
|
238
258
|
timeline's system), see the desktop editor for why there is no
|
|
239
259
|
`prose` wrapper. */
|
|
@@ -244,42 +264,41 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
244
264
|
scope={{ path: file.path ?? file.name, root: file.root, repo: file.repo }}
|
|
245
265
|
/>
|
|
246
266
|
</div>
|
|
247
|
-
) : (
|
|
248
|
-
/* Code Editor with Line Numbers - Desktop styled: gutter bg-canvas border-r border-ink/10 */
|
|
249
|
-
<CodeSurface
|
|
250
|
-
ref={surfaceRef}
|
|
251
|
-
value={content}
|
|
252
|
-
onValueChange={editor.onChange}
|
|
253
|
-
language={lang}
|
|
254
|
-
wordWrap={wordWrap}
|
|
255
|
-
marks={revealMarks}
|
|
256
|
-
currentMark={currentMark}
|
|
257
|
-
rootClassName="flex min-h-full bg-paper"
|
|
258
|
-
// The gutter auto-sizes to its widest number rather than taking a
|
|
259
|
-
// fixed width: the numbers scale with the zoom control, and a
|
|
260
|
-
// pinned `w-10` clipped a 3-digit line number at 18px.
|
|
261
|
-
gutterClassName="flex flex-col text-right pr-2 select-none text-ink/30 bg-canvas border-r border-ink/10 font-mono flex-shrink-0"
|
|
262
|
-
gutterLineClassName="min-w-[1.5rem]"
|
|
263
|
-
gutterStyle={{
|
|
264
|
-
fontSize,
|
|
265
|
-
paddingTop: 12,
|
|
266
|
-
paddingBottom: 12,
|
|
267
|
-
lineHeight: EDITOR_LINE_HEIGHT,
|
|
268
|
-
fontFamily: editorFontFamily,
|
|
269
|
-
}}
|
|
270
|
-
editorWrapperClassName="flex-1 p-3 overflow-x-auto min-w-0 bg-paper text-ink code-surface"
|
|
271
|
-
editorClassName="focus:outline-none"
|
|
272
|
-
editorStyle={{
|
|
273
|
-
fontFamily: editorFontFamily,
|
|
274
|
-
fontSize,
|
|
275
|
-
lineHeight: EDITOR_LINE_HEIGHT,
|
|
276
|
-
backgroundColor: 'transparent',
|
|
277
|
-
minHeight: '100%',
|
|
278
|
-
color: 'var(--theme-ink)',
|
|
279
|
-
}}
|
|
280
|
-
/>
|
|
281
267
|
)}
|
|
282
268
|
</div>
|
|
269
|
+
) : (
|
|
270
|
+
/* The code surface owns its own scroller (CodeMirror's), so it is NOT
|
|
271
|
+
wrapped in one: an outer `overflow-auto` resolves the editor's
|
|
272
|
+
`height: 100%` against an auto-height box, so the editor grew to the
|
|
273
|
+
document's full height and the panel had nothing to scroll. */
|
|
274
|
+
<CodeSurface
|
|
275
|
+
ref={surfaceRef}
|
|
276
|
+
value={content}
|
|
277
|
+
onValueChange={editor.onChange}
|
|
278
|
+
language={lang}
|
|
279
|
+
wordWrap={wordWrap}
|
|
280
|
+
documentKey={fileKey}
|
|
281
|
+
marks={revealMarks}
|
|
282
|
+
currentMark={currentMark}
|
|
283
|
+
onScroller={setCodeScroller}
|
|
284
|
+
scrollTestId="editor-scroller"
|
|
285
|
+
// No `bg-paper`: an explicit editor theme paints its own surface, and
|
|
286
|
+
// the panel's colour under it would be a border of the wrong shade.
|
|
287
|
+
// `select-text` is the desktop pane's rule, and it matters more here:
|
|
288
|
+
// the sheet's own root is `select-none` (it is a header-and-status
|
|
289
|
+
// layout), and the editor is now a `contenteditable` whose selection
|
|
290
|
+
// and caret are the BROWSER's business — an inherited `user-select:
|
|
291
|
+
// none` is the one thing that can take that away on a phone.
|
|
292
|
+
className="flex-1 min-h-0 text-ink select-text"
|
|
293
|
+
themeVars={editorTheme.vars}
|
|
294
|
+
themes={editorTheme.themes}
|
|
295
|
+
padding={12}
|
|
296
|
+
style={{
|
|
297
|
+
fontFamily: editorFontFamily,
|
|
298
|
+
fontSize: `${fontSize}px`,
|
|
299
|
+
lineHeight: EDITOR_LINE_HEIGHT,
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
283
302
|
)}
|
|
284
303
|
|
|
285
304
|
{/* The outline is a floating control rather than a panel section: on a
|
|
@@ -289,7 +308,13 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
|
|
|
289
308
|
target over the content, list in a sheet. Hidden for a byte viewer,
|
|
290
309
|
which has no text to outline. */}
|
|
291
310
|
{!byteViewer && (
|
|
292
|
-
<MobileOutlineButton
|
|
311
|
+
<MobileOutlineButton
|
|
312
|
+
path={file.path ?? null}
|
|
313
|
+
symbols={outlineSymbols}
|
|
314
|
+
total={outlineTotal}
|
|
315
|
+
truncated={outlineTruncated}
|
|
316
|
+
emptyNotice={outlineNotice}
|
|
317
|
+
/>
|
|
293
318
|
)}
|
|
294
319
|
|
|
295
320
|
<EditorStatusBar
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The phone's outline sheet.
|
|
8
|
+
*
|
|
9
|
+
* Same two facts the desktop section shows, on a surface that cannot share its
|
|
10
|
+
* markup (the drawer has no room for a section pinned to a floor, so this is a
|
|
11
|
+
* floating button and a modal). Both are pinned here because each fails
|
|
12
|
+
* silently:
|
|
13
|
+
*
|
|
14
|
+
* - **the button names the TOTAL**, so a truncated list cannot read as "this
|
|
15
|
+
* file has 500 symbols";
|
|
16
|
+
* - **a truncated list says what it is not showing**, in the sheet, where the
|
|
17
|
+
* reader is actually looking.
|
|
18
|
+
*
|
|
19
|
+
* Rendered with `h()` against happy-dom. `Modal` is the shared shell every
|
|
20
|
+
* dialog in the app uses, so opening one here is the same code path a phone
|
|
21
|
+
* takes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
25
|
+
import { Window } from 'happy-dom';
|
|
26
|
+
import { h, render } from 'preact';
|
|
27
|
+
import { act } from 'preact/test-utils';
|
|
28
|
+
import { MobileOutlineButton } from '@/client/components/mobile/mobile-right-sidebar/OutlineButton';
|
|
29
|
+
import { MAX_OUTLINE_ROWS, outlineTruncatedNotice } from '@/shared/lib/code/outline/rows';
|
|
30
|
+
import type { OutlineSymbol } from '@/shared/lib/code/outline/types';
|
|
31
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
32
|
+
|
|
33
|
+
let container: HTMLElement;
|
|
34
|
+
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (container) {
|
|
41
|
+
render(null, container);
|
|
42
|
+
container.remove();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
restoreDomGlobals();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function symbols(count: number): OutlineSymbol[] {
|
|
51
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
52
|
+
name: `fn${i}`,
|
|
53
|
+
kind: 'function' as const,
|
|
54
|
+
line: i + 1,
|
|
55
|
+
column: 0,
|
|
56
|
+
depth: 0,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function mount(props: {
|
|
61
|
+
symbols: OutlineSymbol[];
|
|
62
|
+
total?: number;
|
|
63
|
+
truncated?: boolean;
|
|
64
|
+
path?: string | null;
|
|
65
|
+
emptyNotice?: string;
|
|
66
|
+
}): Promise<void> {
|
|
67
|
+
container = document.createElement('div');
|
|
68
|
+
document.body.appendChild(container);
|
|
69
|
+
await act(async () => {
|
|
70
|
+
render(
|
|
71
|
+
h(MobileOutlineButton, {
|
|
72
|
+
path: props.path === undefined ? 'src/a.ts' : props.path,
|
|
73
|
+
symbols: props.symbols,
|
|
74
|
+
total: props.total ?? props.symbols.length,
|
|
75
|
+
truncated: props.truncated ?? false,
|
|
76
|
+
emptyNotice: props.emptyNotice ?? 'No symbols found.',
|
|
77
|
+
}),
|
|
78
|
+
container,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function trigger(): HTMLButtonElement {
|
|
84
|
+
const el = container.querySelector('button[aria-label^="Outline"]');
|
|
85
|
+
if (!el) throw new Error('no outline trigger rendered');
|
|
86
|
+
return el as HTMLButtonElement;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function open(): Promise<void> {
|
|
90
|
+
await act(async () => {
|
|
91
|
+
trigger().click();
|
|
92
|
+
});
|
|
93
|
+
for (let i = 0; i < 4; i += 1) await act(async () => {});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('MobileOutlineButton', () => {
|
|
97
|
+
test('no path means no control — an attachment chip has nothing to outline', async () => {
|
|
98
|
+
await mount({ symbols: symbols(3), path: null });
|
|
99
|
+
|
|
100
|
+
expect(container.querySelector('button[aria-label^="Outline"]')).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the trigger names the TOTAL, not the window', async () => {
|
|
104
|
+
await mount({ symbols: symbols(MAX_OUTLINE_ROWS), total: 63_618, truncated: true });
|
|
105
|
+
|
|
106
|
+
// The accessible name is what a screen reader announces and what a spec
|
|
107
|
+
// addresses; a window count here would describe the panel, not the file.
|
|
108
|
+
expect(trigger().getAttribute('aria-label')).toBe('Outline (63618 symbols)');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a truncated sheet renders the window and says what it is not showing', async () => {
|
|
112
|
+
await mount({ symbols: symbols(MAX_OUTLINE_ROWS), total: 700, truncated: true });
|
|
113
|
+
await open();
|
|
114
|
+
|
|
115
|
+
const rows = container.querySelectorAll('button[aria-label*=", line "]');
|
|
116
|
+
expect(rows).toHaveLength(MAX_OUTLINE_ROWS);
|
|
117
|
+
expect(container.textContent).toContain(outlineTruncatedNotice(700));
|
|
118
|
+
// The header count reads window/total, so it cannot be mistaken for the
|
|
119
|
+
// number of symbols the file has.
|
|
120
|
+
expect(container.textContent).toContain(`${MAX_OUTLINE_ROWS}/700`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a complete list shows no truncation sentence', async () => {
|
|
124
|
+
await mount({ symbols: symbols(4) });
|
|
125
|
+
await open();
|
|
126
|
+
|
|
127
|
+
expect(container.querySelectorAll('button[aria-label*=", line "]')).toHaveLength(4);
|
|
128
|
+
expect(container.textContent).not.toContain('Showing the first');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('an empty list prints the reason it is empty, not a blank sheet', async () => {
|
|
132
|
+
await mount({ symbols: [], emptyNotice: 'This tab has no outline.' });
|
|
133
|
+
await open();
|
|
134
|
+
|
|
135
|
+
expect(container.textContent).toContain('This tab has no outline.');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -27,15 +27,21 @@ import { Modal } from '@/client/components/common/Modal';
|
|
|
27
27
|
import { OutlineKindIcon, outlineKindLabel } from '@/client/components/common/outline-kind-icon';
|
|
28
28
|
import { setLineReveal } from '@/client/lib/line-reveal';
|
|
29
29
|
import type { OutlineSymbol } from '@/shared/lib/code/outline/types';
|
|
30
|
+
import { outlineTruncatedNotice } from '@/shared/lib/code/outline/rows';
|
|
30
31
|
|
|
31
32
|
interface MobileOutlineButtonProps {
|
|
32
33
|
path: string | null;
|
|
34
|
+
/** The rows to render — already bounded by the hook. */
|
|
33
35
|
symbols: OutlineSymbol[];
|
|
36
|
+
/** Every symbol found, so the button can say when it is showing a window. */
|
|
37
|
+
total: number;
|
|
38
|
+
/** True when `symbols` is only the first `MAX_OUTLINE_ROWS`. */
|
|
39
|
+
truncated: boolean;
|
|
34
40
|
/** Why there is nothing to list, when `symbols` is empty. */
|
|
35
41
|
emptyNotice: string;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlineButtonProps) {
|
|
44
|
+
export function MobileOutlineButton({ path, symbols, total, truncated, emptyNotice }: MobileOutlineButtonProps) {
|
|
39
45
|
const [open, setOpen] = useState(false);
|
|
40
46
|
if (!path) return null;
|
|
41
47
|
|
|
@@ -52,7 +58,7 @@ export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlin
|
|
|
52
58
|
<button
|
|
53
59
|
type="button"
|
|
54
60
|
onClick={() => setOpen(true)}
|
|
55
|
-
aria-label={`Outline (${
|
|
61
|
+
aria-label={`Outline (${total} symbols)`}
|
|
56
62
|
title="Outline"
|
|
57
63
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-ink/20 bg-paper text-ink/60 shadow-sm transition-all hover:bg-ink/5 hover:text-ink active:scale-95"
|
|
58
64
|
>
|
|
@@ -69,7 +75,9 @@ export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlin
|
|
|
69
75
|
<div className="flex min-w-0 items-center gap-2">
|
|
70
76
|
<ListTree size={15} className="flex-shrink-0 text-ink/50" />
|
|
71
77
|
<span className="text-sm font-medium">Outline</span>
|
|
72
|
-
<span className="font-mono text-[11px] text-ink/40">
|
|
78
|
+
<span className="font-mono text-[11px] text-ink/40">
|
|
79
|
+
{truncated ? `${symbols.length}/${total}` : total}
|
|
80
|
+
</span>
|
|
73
81
|
</div>
|
|
74
82
|
}
|
|
75
83
|
>
|
|
@@ -92,6 +100,9 @@ export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlin
|
|
|
92
100
|
</button>
|
|
93
101
|
))
|
|
94
102
|
)}
|
|
103
|
+
{truncated && (
|
|
104
|
+
<div className="px-4 py-2 text-[11px] italic text-ink/40">{outlineTruncatedNotice(total)}</div>
|
|
105
|
+
)}
|
|
95
106
|
</div>
|
|
96
107
|
</Modal>
|
|
97
108
|
)}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Info, Monitor, Settings } from 'lucide-preact';
|
|
2
2
|
import packageJson from '@/../package.json';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
shellDownloadShortLabel,
|
|
6
|
-
type ShellDownload,
|
|
7
|
-
} from '@/client/hooks/ui/updates/shell-download';
|
|
3
|
+
import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
|
|
4
|
+
import { ShellUpdateButton } from '@/client/components/common/shell-update-button';
|
|
8
5
|
|
|
9
6
|
interface MobileSessionFooterProps {
|
|
10
7
|
onSettings: () => void;
|
|
@@ -49,27 +46,18 @@ export function MobileSessionFooter({
|
|
|
49
46
|
<span>About</span>
|
|
50
47
|
</button>
|
|
51
48
|
{shellDownload && (
|
|
52
|
-
// Directly after About, matching the desktop footer.
|
|
53
|
-
// button: the download is the browser's (or the WebView's) job, and
|
|
54
|
-
// `target="_blank"` hands it to the system browser so the console is
|
|
55
|
-
// not replaced by a file download.
|
|
49
|
+
// Directly after About, matching the desktop footer.
|
|
56
50
|
//
|
|
57
51
|
// The visible word is the OFFER, not the file type: `APK` told a
|
|
58
52
|
// reader what the bytes are but not what the control does, and on a
|
|
59
53
|
// browser it is the only place the native shell is ever mentioned. An
|
|
60
54
|
// iOS update is an IPA, so the word names the platform instead of a
|
|
61
55
|
// file extension.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
aria-label={shellDownloadLabel(shellDownload)}
|
|
68
|
-
className="flex items-center space-x-1 hover:text-ink transition-colors"
|
|
69
|
-
>
|
|
70
|
-
<Download size={14} />
|
|
71
|
-
<span>{shellDownloadShortLabel(shellDownload)}</span>
|
|
72
|
-
</a>
|
|
56
|
+
//
|
|
57
|
+
// The shared control decides link-versus-button: a desktop shell that
|
|
58
|
+
// knows its architecture installs the artifact itself, and a link
|
|
59
|
+
// there would leave the file in Downloads.
|
|
60
|
+
<ShellUpdateButton download={shellDownload} variant="label" />
|
|
73
61
|
)}
|
|
74
62
|
{onDesktopToggle && (
|
|
75
63
|
<button
|
|
@@ -9,7 +9,9 @@ import { useRestartKabmo } from '@/client/hooks/settings/restart-kabmo';
|
|
|
9
9
|
import { applyDocumentTheme } from '@/client/hooks/ui/theme';
|
|
10
10
|
import { DEFAULT_THEME_ID } from '@/shared/lib/theme/catalog';
|
|
11
11
|
import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
|
|
12
|
+
import { EDITOR_DEFAULT_THEME_ID } from '@/shared/lib/code/editor/themes';
|
|
12
13
|
import { EDITOR_FONT_CHANGED_EVENT } from '@/client/hooks/editor/font';
|
|
14
|
+
import { EDITOR_THEME_CHANGED_EVENT } from '@/client/hooks/editor/theme';
|
|
13
15
|
import { useKabmoEvent } from '@/client/hooks/ui/window-event';
|
|
14
16
|
import { SETTINGS_CATEGORIES, SettingsSidebar } from '@/client/components/settings/Sidebar';
|
|
15
17
|
import { AppearanceSettings } from '@/client/components/settings/categories/AppearanceSettings';
|
|
@@ -41,6 +43,7 @@ export interface SettingsModalProps {
|
|
|
41
43
|
const DEFAULT_SETTINGS: SettingsState = {
|
|
42
44
|
theme: DEFAULT_THEME_ID,
|
|
43
45
|
editorFont: EDITOR_DEFAULT_FONT_FAMILY,
|
|
46
|
+
editorTheme: EDITOR_DEFAULT_THEME_ID,
|
|
44
47
|
autoSessionTitle: true,
|
|
45
48
|
soundAlerts: true,
|
|
46
49
|
chatCompletionSound: true,
|
|
@@ -120,6 +123,11 @@ export function SettingsModal({
|
|
|
120
123
|
if (typeof window !== 'undefined' && next.editorFont !== prev.editorFont) {
|
|
121
124
|
window.dispatchEvent(new CustomEvent(EDITOR_FONT_CHANGED_EVENT, { detail: next.editorFont }));
|
|
122
125
|
}
|
|
126
|
+
// Same rule for the editor's colour theme: it is visual, and every open
|
|
127
|
+
// editor subscribes rather than reading the boot-time `appSettings`.
|
|
128
|
+
if (typeof window !== 'undefined' && next.editorTheme !== prev.editorTheme) {
|
|
129
|
+
window.dispatchEvent(new CustomEvent(EDITOR_THEME_CHANGED_EVENT, { detail: next.editorTheme }));
|
|
130
|
+
}
|
|
123
131
|
// Only the keys this update actually touched are persisted. The modal
|
|
124
132
|
// holds a full `SettingsState` snapshot taken when it opened, so writing
|
|
125
133
|
// the whole object back would also republish every value it read at that
|
|
@@ -3,6 +3,7 @@ import type { SettingsState } from '@/shared/types';
|
|
|
3
3
|
import { resolveThemeId } from '@/shared/lib/theme/catalog';
|
|
4
4
|
import { ThemeGrid } from '@/client/components/settings/categories/appearance-settings/ThemeGrid';
|
|
5
5
|
import { EditorFontSection } from '@/client/components/settings/categories/appearance-settings/EditorFontSection';
|
|
6
|
+
import { EditorThemeSection } from '@/client/components/settings/categories/appearance-settings/EditorThemeSection';
|
|
6
7
|
|
|
7
8
|
interface AppearanceSettingsProps {
|
|
8
9
|
settings: SettingsState;
|
|
@@ -25,6 +26,8 @@ export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsPro
|
|
|
25
26
|
</section>
|
|
26
27
|
|
|
27
28
|
<EditorFontSection settings={settings} onUpdate={onUpdate} />
|
|
29
|
+
|
|
30
|
+
<EditorThemeSection settings={settings} onUpdate={onUpdate} />
|
|
28
31
|
</div>
|
|
29
32
|
);
|
|
30
33
|
}
|
package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The editor theme picker.
|
|
8
|
+
*
|
|
9
|
+
* Three properties are invisible to a markup check and each is a bug if dropped:
|
|
10
|
+
*
|
|
11
|
+
* - **`Auto` is a card and is selected by default**, so an install that never
|
|
12
|
+
* touches this section renders exactly what it did before the feature.
|
|
13
|
+
* - **The swatch is drawn from the row's own colours**, not from the app
|
|
14
|
+
* palette: a card that previewed every theme in the app's ink would make 65
|
|
15
|
+
* themes look identical, which is the whole point of the grid.
|
|
16
|
+
* - **A click writes the theme id through `onUpdate`**, which is what the modal
|
|
17
|
+
* turns into the event an already-open editor subscribes to.
|
|
18
|
+
*
|
|
19
|
+
* Rendered with `h()` against happy-dom; the modal's write path is pinned in
|
|
20
|
+
* `settings/Modal`'s own suite.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
24
|
+
import { Window } from 'happy-dom';
|
|
25
|
+
import { h, render } from 'preact';
|
|
26
|
+
import { act } from 'preact/test-utils';
|
|
27
|
+
|
|
28
|
+
import { EditorThemeSection } from '@/client/components/settings/categories/appearance-settings/EditorThemeSection';
|
|
29
|
+
import { EDITOR_AUTO_THEME_ID, EDITOR_THEMES, resolveEditorTheme } from '@/shared/lib/code/editor/themes';
|
|
30
|
+
import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
|
|
31
|
+
import type { SettingsState } from '@/shared/types';
|
|
32
|
+
|
|
33
|
+
const DOM_GLOBALS = [
|
|
34
|
+
'window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent',
|
|
35
|
+
'getComputedStyle',
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
const native: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
const win = new Window({ url: 'http://localhost' });
|
|
42
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
43
|
+
for (const key of DOM_GLOBALS) {
|
|
44
|
+
if (!(key in native)) native[key] = target[key];
|
|
45
|
+
const value = (win as unknown as Record<string, unknown>)[key];
|
|
46
|
+
if (value !== undefined) target[key] = value;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
52
|
+
for (const key of DOM_GLOBALS) {
|
|
53
|
+
if (native[key] === undefined) delete target[key];
|
|
54
|
+
else target[key] = native[key];
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let container: HTMLElement | null = null;
|
|
59
|
+
type Update = Partial<SettingsState> | ((prev: SettingsState) => SettingsState);
|
|
60
|
+
let lastUpdate: Update | null = null;
|
|
61
|
+
|
|
62
|
+
/** Reset through a function: assigning `null` inline would narrow the variable
|
|
63
|
+
* at every later read, and the assertions below exist to see the VALUE. */
|
|
64
|
+
function resetUpdate(): void {
|
|
65
|
+
lastUpdate = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What the last click asked to persist, as a value the assertions can read. */
|
|
69
|
+
function lastPatch(): unknown {
|
|
70
|
+
return lastUpdate;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function settings(editorTheme: string): SettingsState {
|
|
74
|
+
return { editorTheme, editorFont: EDITOR_DEFAULT_FONT_FAMILY } as SettingsState;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function mount(editorTheme: string): Promise<HTMLElement> {
|
|
78
|
+
if (container) render(null, container);
|
|
79
|
+
container = document.body.appendChild(document.createElement('div'));
|
|
80
|
+
await act(async () => {
|
|
81
|
+
render(
|
|
82
|
+
h(EditorThemeSection, {
|
|
83
|
+
settings: settings(editorTheme),
|
|
84
|
+
onUpdate: (update: Update) => {
|
|
85
|
+
lastUpdate = update;
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
container!,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
return container;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The card whose label starts with `label` (the label is the first text). */
|
|
95
|
+
function card(root: HTMLElement, label: string): HTMLButtonElement | undefined {
|
|
96
|
+
return Array.from(root.querySelectorAll('button')).find((button) =>
|
|
97
|
+
(button.textContent ?? '').trim().startsWith(label),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('editor theme picker', () => {
|
|
102
|
+
test('Auto is a card, and it is the selected one by default', async () => {
|
|
103
|
+
const root = await mount(EDITOR_AUTO_THEME_ID);
|
|
104
|
+
const auto = card(root, 'Auto');
|
|
105
|
+
expect(auto).toBeDefined();
|
|
106
|
+
expect(auto!.getAttribute('aria-pressed')).toBe('true');
|
|
107
|
+
// Every shipped theme is offered beside it, so the grid is not a two-item
|
|
108
|
+
// list with the catalogue hidden behind a filter.
|
|
109
|
+
expect(root.querySelectorAll('button[aria-pressed]').length).toBe(EDITOR_THEMES.length + 1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('a stored theme is the selected card, and Auto is not', async () => {
|
|
113
|
+
const root = await mount('dracula');
|
|
114
|
+
expect(card(root, 'Dracula Theme')!.getAttribute('aria-pressed')).toBe('true');
|
|
115
|
+
expect(card(root, 'Auto')!.getAttribute('aria-pressed')).toBe('false');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('the swatch is drawn in the theme’s OWN colours', async () => {
|
|
119
|
+
const root = await mount('dracula');
|
|
120
|
+
const theme = resolveEditorTheme('dracula');
|
|
121
|
+
expect(theme).not.toBeNull();
|
|
122
|
+
const swatch = Array.from(card(root, 'Dracula Theme')!.querySelectorAll('span')).find(
|
|
123
|
+
(node) => (node as HTMLElement).style.backgroundColor !== '',
|
|
124
|
+
) as HTMLElement | undefined;
|
|
125
|
+
expect(swatch).toBeDefined();
|
|
126
|
+
// The card paints the theme's own surface, not the app's — which is what
|
|
127
|
+
// makes 65 themes distinguishable in one grid.
|
|
128
|
+
expect(swatch!.style.backgroundColor.toLowerCase()).toBe(theme!.bg.toLowerCase());
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a click writes the theme id', async () => {
|
|
132
|
+
resetUpdate();
|
|
133
|
+
const root = await mount(EDITOR_AUTO_THEME_ID);
|
|
134
|
+
await act(async () => {
|
|
135
|
+
card(root, 'Nord')!.click();
|
|
136
|
+
});
|
|
137
|
+
expect(lastPatch()).toEqual({ editorTheme: 'nord' });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the Auto card writes the sentinel, not an empty string', async () => {
|
|
141
|
+
resetUpdate();
|
|
142
|
+
const root = await mount('nord');
|
|
143
|
+
await act(async () => {
|
|
144
|
+
card(root, 'Auto')!.click();
|
|
145
|
+
});
|
|
146
|
+
// An empty value would resolve to `auto` too, but it would also be a value
|
|
147
|
+
// no reader can distinguish from a missing setting.
|
|
148
|
+
expect(lastPatch()).toEqual({ editorTheme: EDITOR_AUTO_THEME_ID });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('the light/dark filters narrow the grid without dropping Auto', async () => {
|
|
152
|
+
const root = await mount(EDITOR_AUTO_THEME_ID);
|
|
153
|
+
const darkFilter = Array.from(root.querySelectorAll('button')).find(
|
|
154
|
+
(button) => (button.textContent ?? '').trim() === 'Dark',
|
|
155
|
+
);
|
|
156
|
+
await act(async () => {
|
|
157
|
+
darkFilter!.click();
|
|
158
|
+
});
|
|
159
|
+
const labels = Array.from(root.querySelectorAll('button[aria-pressed]')).map((button) =>
|
|
160
|
+
(button.textContent ?? '').trim().split(/\d/)[0],
|
|
161
|
+
);
|
|
162
|
+
expect(labels.length).toBe(EDITOR_THEMES.filter((theme) => theme.variant === 'dark').length + 1);
|
|
163
|
+
expect(labels.some((label) => label.startsWith('GitHub Light'))).toBe(false);
|
|
164
|
+
// Auto is a choice, not a light/dark theme, so a variant filter keeps it.
|
|
165
|
+
expect(labels.some((label) => label.startsWith('Auto'))).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|