@kabmo-labs/kabmo 0.7.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 +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -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/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -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/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- 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/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- 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/ProjectSettings.tsx +22 -8
- 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 +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- 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/index.tsx +5 -0
- 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.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- 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/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- 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/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- 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/components/workspace/worktree-modal/index.tsx +261 -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 +27 -28
- 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/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- 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/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- 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/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -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-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- 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/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- 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 +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- 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/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- 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/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- 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.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- 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/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -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-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- 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/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -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 +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- 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-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.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/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
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* overwrite the position this hook exists to keep.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { useCallback, useContext, useLayoutEffect, useRef } from 'preact/hooks';
|
|
35
|
+
import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'preact/hooks';
|
|
36
36
|
import { SessionStateContext } from '@/client/hooks/workspace/session-state/context';
|
|
37
37
|
import { getSessionValue, setSessionKey } from '@/shared/lib/workspace/session-state/store';
|
|
38
38
|
import {
|
|
@@ -52,6 +52,29 @@ const SAVE_DEBOUNCE_MS = 150;
|
|
|
52
52
|
/** Scroll deltas below this are rounding, not a move. */
|
|
53
53
|
const NOISE_PX = 1;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The cancel hook of every restore currently armed, by scroller.
|
|
57
|
+
*
|
|
58
|
+
* A pending restore REPLAYS while the document settles, and it re-applies
|
|
59
|
+
* through a `ResizeObserver` — which can fire BEFORE the scroll event a
|
|
60
|
+
* programmatic reveal causes. Measured: an outline jump scrolled a 2 520-line
|
|
61
|
+
* file to 34 695 px and the observer wrote the stored 0 back 375 ms later, so
|
|
62
|
+
* the caret moved and the view stayed at the top.
|
|
63
|
+
*
|
|
64
|
+
* An explicit reveal therefore has to CANCEL the restore rather than race it,
|
|
65
|
+
* and the surface that scrolls is the one that knows. This registry is that
|
|
66
|
+
* hand-off: keyed by the element, so a caller needs no reference to the hook
|
|
67
|
+
* instance that armed it (the surface owns the scroller; the panel owns the
|
|
68
|
+
* hook).
|
|
69
|
+
*/
|
|
70
|
+
const armedRestores = new Map<HTMLElement, () => void>();
|
|
71
|
+
|
|
72
|
+
/** Stop a pending restore of `element` — a reveal wins over a remembered offset. */
|
|
73
|
+
export function cancelScrollRestore(element: HTMLElement | null): void {
|
|
74
|
+
if (!element) return;
|
|
75
|
+
armedRestores.get(element)?.();
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
/** What a caller attaches to its scroll container. */
|
|
56
79
|
export interface ScrollRestoreBinding {
|
|
57
80
|
ref: (element: HTMLElement | null) => void;
|
|
@@ -67,6 +90,16 @@ export interface UseScrollRestoreOptions {
|
|
|
67
90
|
ready?: boolean;
|
|
68
91
|
/** The session-state slot to read/write; the editor column's by default. */
|
|
69
92
|
slot?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The element to restore, for a caller that does not own a `ref` on it.
|
|
95
|
+
*
|
|
96
|
+
* The code surface's scroller belongs to CodeMirror (`view.scrollDOM`) and
|
|
97
|
+
* exists only after the view mounts, so the surface reports it through
|
|
98
|
+
* `onScroller` and the caller passes it here. `undefined` means "this caller
|
|
99
|
+
* uses `ref`"; `null` means "the element is not there yet", which re-arms the
|
|
100
|
+
* restore on the next render that has one.
|
|
101
|
+
*/
|
|
102
|
+
element?: HTMLElement | null;
|
|
70
103
|
}
|
|
71
104
|
|
|
72
105
|
export function useScrollRestore({
|
|
@@ -74,6 +107,7 @@ export function useScrollRestore({
|
|
|
74
107
|
surface,
|
|
75
108
|
ready = true,
|
|
76
109
|
slot = SCROLL_POSITIONS_KEY,
|
|
110
|
+
element: providedElement,
|
|
77
111
|
}: UseScrollRestoreOptions): ScrollRestoreBinding {
|
|
78
112
|
// Read OPTIONALLY: the shared diff views also draw a chat tool card, and a
|
|
79
113
|
// caller may mount one without the provider. A null context makes the whole
|
|
@@ -100,6 +134,37 @@ export function useScrollRestore({
|
|
|
100
134
|
* whose height is still growing then stayed where the clamp left it.
|
|
101
135
|
*/
|
|
102
136
|
const writingRef = useRef(false);
|
|
137
|
+
/**
|
|
138
|
+
* What the element's `scrollTop` was at the last observation, so a scroll
|
|
139
|
+
* event that moves it WITHOUT anyone assigning can be recognised as the
|
|
140
|
+
* browser's own anchoring rather than the reader's.
|
|
141
|
+
*
|
|
142
|
+
* This is the bug the e2e spec found on CI and could not explain: returning to
|
|
143
|
+
* a long file landed the reader on line 99 instead of line 70, and the wrong
|
|
144
|
+
* offset was then persisted. Measured in the browser: the restore wrote 1255,
|
|
145
|
+
* the document's real height arrived a frame later (5796 → 7232), and
|
|
146
|
+
* CodeMirror re-anchored — `scrollTop` became 1777 with no assignment from
|
|
147
|
+
* this hook. That produced a `scroll` event, the handler below compared it
|
|
148
|
+
* against `restoredTopRef` (1255), read a 522px delta, and treated it as the
|
|
149
|
+
* reader's own scroll: the pending restore was dropped, and the anchored
|
|
150
|
+
* value became what got stored. The reader moved 29 lines for opening a tab.
|
|
151
|
+
*
|
|
152
|
+
* An anchoring correction is not a reader. It is re-applied like any other
|
|
153
|
+
* height change, because the target offset is the one the session recorded.
|
|
154
|
+
*/
|
|
155
|
+
const observedTopRef = useRef(0);
|
|
156
|
+
/**
|
|
157
|
+
* Whether the reader has DONE something since the last scroll event — the
|
|
158
|
+
* only thing that separates their scroll from the browser's own.
|
|
159
|
+
*
|
|
160
|
+
* Every way a reader scrolls a code surface starts with an input event: a
|
|
161
|
+
* wheel, a key (arrows, PageDown, Home/End), a pointer press on the scrollbar
|
|
162
|
+
* or a drag-select, a touch. CodeMirror re-anchoring starts with none, so a
|
|
163
|
+
* `scroll` with no input behind it is the document settling. The flag is
|
|
164
|
+
* cleared by the scroll it explains, so the next unattributed move is read as
|
|
165
|
+
* anchoring again.
|
|
166
|
+
*/
|
|
167
|
+
const readerIntentRef = useRef(false);
|
|
103
168
|
/** The last offset the READER produced, persisted on the way out. */
|
|
104
169
|
const lastTopRef = useRef<number | null>(null);
|
|
105
170
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
@@ -113,13 +178,17 @@ export function useScrollRestore({
|
|
|
113
178
|
pendingRef.current = null;
|
|
114
179
|
return;
|
|
115
180
|
}
|
|
116
|
-
if (Math.abs(element.scrollTop - pending.top) < NOISE_PX)
|
|
181
|
+
if (Math.abs(element.scrollTop - pending.top) < NOISE_PX) {
|
|
182
|
+
observedTopRef.current = element.scrollTop;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
117
185
|
writingRef.current = true;
|
|
118
186
|
element.scrollTop = pending.top;
|
|
119
187
|
writingRef.current = false;
|
|
120
188
|
// Read back: the document may be shorter than the target, and the clamped
|
|
121
189
|
// value is what an asynchronous scroll event will report.
|
|
122
190
|
restoredTopRef.current = element.scrollTop;
|
|
191
|
+
observedTopRef.current = element.scrollTop;
|
|
123
192
|
}, []);
|
|
124
193
|
|
|
125
194
|
const attach = useCallback(
|
|
@@ -152,9 +221,23 @@ export function useScrollRestore({
|
|
|
152
221
|
// because the event may arrive synchronously (during the assignment) or
|
|
153
222
|
// on a later frame (against the value the write landed on).
|
|
154
223
|
if (writingRef.current) return;
|
|
224
|
+
// Moved with nobody assigning AND no reader input behind it: CodeMirror
|
|
225
|
+
// re-anchoring after the document's real height landed. NOT the reader —
|
|
226
|
+
// re-applying the recorded offset is what keeps them where they were. See
|
|
227
|
+
// `observedTopRef` for the measurement.
|
|
228
|
+
const moved = Math.abs(top - observedTopRef.current) >= NOISE_PX;
|
|
229
|
+
observedTopRef.current = top;
|
|
230
|
+
if (moved && !readerIntentRef.current) {
|
|
231
|
+
applyPending();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
155
234
|
if (Math.abs(top - restoredTopRef.current) < NOISE_PX * 2) return;
|
|
156
235
|
pendingRef.current = null;
|
|
157
236
|
}
|
|
237
|
+
observedTopRef.current = top;
|
|
238
|
+
// The input this scroll belongs to has now been accounted for; the next
|
|
239
|
+
// unattributed move is anchoring again.
|
|
240
|
+
readerIntentRef.current = false;
|
|
158
241
|
lastTopRef.current = top;
|
|
159
242
|
clearTimeout(saveTimerRef.current);
|
|
160
243
|
saveTimerRef.current = setTimeout(() => {
|
|
@@ -162,14 +245,47 @@ export function useScrollRestore({
|
|
|
162
245
|
const value = lastTopRef.current;
|
|
163
246
|
if (value !== null) persist(value);
|
|
164
247
|
}, SAVE_DEBOUNCE_MS);
|
|
165
|
-
}, [persist, tabKey]);
|
|
248
|
+
}, [applyPending, persist, tabKey]);
|
|
249
|
+
|
|
250
|
+
// A provided element is not a React-rendered node, so its scroll events have
|
|
251
|
+
// to be wired here: the caller has no `onScroll` prop to hang them on. The
|
|
252
|
+
// ref path keeps the caller's own `onScroll`.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!providedElement) return;
|
|
255
|
+
const listener = () => onScroll();
|
|
256
|
+
providedElement.addEventListener('scroll', listener, { passive: true });
|
|
257
|
+
return () => providedElement.removeEventListener('scroll', listener);
|
|
258
|
+
}, [providedElement, onScroll]);
|
|
259
|
+
|
|
260
|
+
// What counts as the reader having scrolled, for `readerIntentRef`. Wired on
|
|
261
|
+
// the element AND the window, because a keydown goes to the focused editor
|
|
262
|
+
// while a wheel over the gutter goes to the window's capture phase.
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
const element = providedElement ?? elementRef.current;
|
|
265
|
+
if (!element) return;
|
|
266
|
+
const note = () => {
|
|
267
|
+
readerIntentRef.current = true;
|
|
268
|
+
};
|
|
269
|
+
const types = ['wheel', 'keydown', 'pointerdown', 'touchstart'] as const;
|
|
270
|
+
for (const type of types) element.addEventListener(type, note, { passive: true });
|
|
271
|
+
window.addEventListener('keydown', note, { passive: true });
|
|
272
|
+
return () => {
|
|
273
|
+
for (const type of types) element.removeEventListener(type, note);
|
|
274
|
+
window.removeEventListener('keydown', note);
|
|
275
|
+
};
|
|
276
|
+
}, [providedElement]);
|
|
166
277
|
|
|
167
278
|
// A layout effect so the restore lands in the same frame the tab paints — a
|
|
168
279
|
// passive effect would show the document's head for one frame first.
|
|
169
280
|
useLayoutEffect(() => {
|
|
170
281
|
pendingRef.current = null;
|
|
171
282
|
lastTopRef.current = null;
|
|
283
|
+
readerIntentRef.current = false;
|
|
172
284
|
let observer: ResizeObserver | null = null;
|
|
285
|
+
// A provided element WINS over the ref: the caller handed us the element it
|
|
286
|
+
// wants restored, and a stale ref from the other surface of the same tab
|
|
287
|
+
// must not be restored instead.
|
|
288
|
+
if (providedElement !== undefined) elementRef.current = providedElement;
|
|
173
289
|
const element = elementRef.current;
|
|
174
290
|
|
|
175
291
|
if (ready && storeReady && tabKey && element) {
|
|
@@ -179,6 +295,9 @@ export function useScrollRestore({
|
|
|
179
295
|
// of it. "No stored offset" means the top, and the top is where they go.
|
|
180
296
|
const top = readScrollTop(getSessionValue<ScrollPositions>(sessionId, slot), tabKey, surface);
|
|
181
297
|
pendingRef.current = { top, until: performance.now() + RESTORE_WINDOW_MS };
|
|
298
|
+
armedRestores.set(element, () => {
|
|
299
|
+
pendingRef.current = null;
|
|
300
|
+
});
|
|
182
301
|
applyPending();
|
|
183
302
|
// The document's own height is what moves after this commit (measurement,
|
|
184
303
|
// a width animation, spacers landing), so its growth is what re-applies.
|
|
@@ -190,6 +309,7 @@ export function useScrollRestore({
|
|
|
190
309
|
}
|
|
191
310
|
|
|
192
311
|
return () => {
|
|
312
|
+
if (element) armedRestores.delete(element);
|
|
193
313
|
observer?.disconnect();
|
|
194
314
|
clearTimeout(saveTimerRef.current);
|
|
195
315
|
saveTimerRef.current = undefined;
|
|
@@ -203,7 +323,7 @@ export function useScrollRestore({
|
|
|
203
323
|
};
|
|
204
324
|
// `applyPending`/`persist` are stable, and re-running this effect restarts
|
|
205
325
|
// the restore — which is exactly what a tab or surface change needs.
|
|
206
|
-
}, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist]);
|
|
326
|
+
}, [ready, storeReady, tabKey, surface, sessionId, slot, applyPending, persist, providedElement]);
|
|
207
327
|
|
|
208
328
|
return { ref: attach, onScroll };
|
|
209
329
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
30
30
|
import { useSyncExternalStore } from 'preact/compat';
|
|
31
31
|
|
|
32
|
-
import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find';
|
|
32
|
+
import { findMatches, lineStartOffset, matchIndexAtOrAfter } from '@/shared/lib/code/editor/find-scan';
|
|
33
33
|
import {
|
|
34
34
|
getSearchReveal,
|
|
35
35
|
setSearchReveal,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Long press: the only way a finger reaches a Files-panel row's menu.
|
|
8
|
+
*
|
|
9
|
+
* Three rules are load-bearing and each was a way the gesture would misbehave
|
|
10
|
+
* on a phone:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A press that MOVES is a scroll.** The tree is a scroller, and a hold that
|
|
13
|
+
* begins a flick must cancel — otherwise every scroll that starts on a row
|
|
14
|
+
* opens a menu.
|
|
15
|
+
* 2. **The click the same gesture produces is swallowed.** Lifting the finger
|
|
16
|
+
* after the menu opened would otherwise also toggle the folder it opened for;
|
|
17
|
+
* the platform raises both events for one gesture and marks neither.
|
|
18
|
+
* 3. **It is inert without a finger.** A desktop holding its primary button down
|
|
19
|
+
* is a drag, and a mouse already has a right button.
|
|
20
|
+
*
|
|
21
|
+
* Time is driven, never slept through: a 450 ms hold is a real wait, and the
|
|
22
|
+
* hook's decisions are all about WHICH event arrives, never about how long the
|
|
23
|
+
* test took.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test';
|
|
27
|
+
import { Window } from 'happy-dom';
|
|
28
|
+
import { h, render } from 'preact';
|
|
29
|
+
import { act } from 'preact/test-utils';
|
|
30
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
31
|
+
import { useLongPress, type LongPressHandlers } from '@/client/hooks/ui/long-press';
|
|
32
|
+
|
|
33
|
+
let win: Window;
|
|
34
|
+
let container: HTMLElement;
|
|
35
|
+
let handlers: LongPressHandlers | null = null;
|
|
36
|
+
const fired: Array<{ x: number; y: number }> = [];
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
win = new Window({ url: 'http://localhost' });
|
|
41
|
+
installDomGlobals(win);
|
|
42
|
+
container = document.createElement('div');
|
|
43
|
+
document.body.appendChild(container);
|
|
44
|
+
fired.length = 0;
|
|
45
|
+
handlers = null;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
render(null, container);
|
|
50
|
+
container.remove();
|
|
51
|
+
restoreDomGlobals();
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Mount the hook; `enabled` is what a touch device decides. */
|
|
56
|
+
async function mount(enabled: boolean): Promise<void> {
|
|
57
|
+
const Probe = () => {
|
|
58
|
+
handlers = useLongPress({ enabled, onLongPress: (x, y) => fired.push({ x, y }) });
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
await act(async () => {
|
|
62
|
+
render(h(Probe, {}), container);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A touch event carrying one finger at `(x, y)`. */
|
|
67
|
+
function touch(type: string, x: number, y: number): globalThis.TouchEvent {
|
|
68
|
+
const event = new win.Event(type, { bubbles: true, cancelable: true }) as unknown as globalThis.TouchEvent;
|
|
69
|
+
Object.defineProperty(event, 'touches', { value: type === 'touchend' || type === 'touchcancel' ? [] : [{ clientX: x, clientY: y }] });
|
|
70
|
+
return event;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Advance the hook's own timer, without waiting on a real clock. */
|
|
74
|
+
async function holdFor(ms: number): Promise<void> {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
vi.advanceTimersByTime(ms);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('useLongPress', () => {
|
|
81
|
+
test('a hold fires at the press point', async () => {
|
|
82
|
+
await mount(true);
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
86
|
+
});
|
|
87
|
+
await holdFor(520);
|
|
88
|
+
|
|
89
|
+
expect(fired).toEqual([{ x: 40, y: 80 }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a press released before the threshold never fires', async () => {
|
|
93
|
+
await mount(true);
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
97
|
+
});
|
|
98
|
+
await act(async () => {
|
|
99
|
+
handlers!.onTouchEnd();
|
|
100
|
+
});
|
|
101
|
+
await holdFor(520);
|
|
102
|
+
|
|
103
|
+
expect(fired).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a press that MOVES is a scroll, not a press', async () => {
|
|
107
|
+
await mount(true);
|
|
108
|
+
|
|
109
|
+
await act(async () => {
|
|
110
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
111
|
+
// Past the slop: the first few pixels of a flick arrive immediately, and
|
|
112
|
+
// a menu that opened on them would make the tree unscrollable.
|
|
113
|
+
handlers!.onTouchMove(touch('touchmove', 40, 140));
|
|
114
|
+
});
|
|
115
|
+
await holdFor(520);
|
|
116
|
+
|
|
117
|
+
expect(fired).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a small drift inside the slop still counts as a hold', async () => {
|
|
121
|
+
await mount(true);
|
|
122
|
+
|
|
123
|
+
await act(async () => {
|
|
124
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
125
|
+
handlers!.onTouchMove(touch('touchmove', 42, 83));
|
|
126
|
+
});
|
|
127
|
+
await holdFor(520);
|
|
128
|
+
|
|
129
|
+
expect(fired).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a cancelled press never fires', async () => {
|
|
133
|
+
await mount(true);
|
|
134
|
+
|
|
135
|
+
await act(async () => {
|
|
136
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
137
|
+
handlers!.onTouchCancel();
|
|
138
|
+
});
|
|
139
|
+
await holdFor(520);
|
|
140
|
+
|
|
141
|
+
expect(fired).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the click after a fired press is consumed exactly once', async () => {
|
|
145
|
+
await mount(true);
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
149
|
+
});
|
|
150
|
+
await holdFor(520);
|
|
151
|
+
|
|
152
|
+
// The first click belongs to the gesture that opened the menu; a second
|
|
153
|
+
// click is the user's own and must work.
|
|
154
|
+
expect(handlers!.consume()).toBe(true);
|
|
155
|
+
expect(handlers!.consume()).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('no press means nothing to consume', async () => {
|
|
159
|
+
await mount(true);
|
|
160
|
+
|
|
161
|
+
expect(handlers!.consume()).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('it is inert without a finger', async () => {
|
|
165
|
+
await mount(false);
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
169
|
+
});
|
|
170
|
+
await holdFor(520);
|
|
171
|
+
|
|
172
|
+
expect(fired).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Long press, for the surfaces that have no right-click.
|
|
8
|
+
*
|
|
9
|
+
* A phone's Files panel offered no way to reach a row's context menu: the menu
|
|
10
|
+
* opens on `contextmenu`, which a touch device only raises after a press that
|
|
11
|
+
* the browser has already claimed for something else — a native drag (the row
|
|
12
|
+
* is a drag source) or a text selection of the whole panel. Measured on a
|
|
13
|
+
* phone: holding a file row selected the tree's text and started a drag ghost,
|
|
14
|
+
* and no menu ever appeared.
|
|
15
|
+
*
|
|
16
|
+
* Three rules make this usable rather than a second way to break the panel:
|
|
17
|
+
*
|
|
18
|
+
* - **A press that MOVES is a scroll, not a press.** The tree is a scroller, and
|
|
19
|
+
* a hold that begins a scroll must cancel — otherwise every flick that starts
|
|
20
|
+
* on a row opens a menu. The threshold is small (`SLOP_PX`) because the first
|
|
21
|
+
* few pixels of a scroll arrive within the same tick.
|
|
22
|
+
* - **A fired press suppresses the CLICK it would otherwise produce.** Lifting
|
|
23
|
+
* the finger after the menu opened would otherwise also toggle the folder the
|
|
24
|
+
* menu was opened for. The caller asks `consume()` from its click handler,
|
|
25
|
+
* which is the only place the two can be told apart — the platform raises both
|
|
26
|
+
* events for one gesture and does not mark either.
|
|
27
|
+
* - **It is inert without a touch device.** A mouse already has a right button,
|
|
28
|
+
* and a desktop holding its primary button down over a row is a drag.
|
|
29
|
+
*
|
|
30
|
+
* The handler is deliberately not `onContextMenu`-based: some Android builds DO
|
|
31
|
+
* raise `contextmenu` after a hold, and the browser's own press has already
|
|
32
|
+
* started a selection by then, so the menu would open over a half-selected tree.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { useCallback, useEffect, useRef } from 'preact/hooks';
|
|
36
|
+
|
|
37
|
+
/** How long a press must be held before it counts. */
|
|
38
|
+
const LONG_PRESS_MS = 450;
|
|
39
|
+
/** Movement that turns a press into a scroll, in CSS pixels. */
|
|
40
|
+
const SLOP_PX = 8;
|
|
41
|
+
/** How long a fired press keeps suppressing the click it produces. */
|
|
42
|
+
const CLICK_SUPPRESS_MS = 700;
|
|
43
|
+
|
|
44
|
+
export interface LongPressOptions {
|
|
45
|
+
/** False on a device with a pointer: the right button already does this. */
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
/** Where the press landed, in viewport coordinates — a menu anchor. */
|
|
48
|
+
onLongPress: (x: number, y: number) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LongPressHandlers {
|
|
52
|
+
onTouchStart: (event: globalThis.TouchEvent) => void;
|
|
53
|
+
onTouchMove: (event: globalThis.TouchEvent) => void;
|
|
54
|
+
onTouchEnd: () => void;
|
|
55
|
+
onTouchCancel: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* True exactly once after a press fired, so the caller's click handler can
|
|
58
|
+
* ignore the click the same gesture produces.
|
|
59
|
+
*/
|
|
60
|
+
consume: () => boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useLongPress({ enabled, onLongPress }: LongPressOptions): LongPressHandlers {
|
|
64
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
65
|
+
const originRef = useRef<{ x: number; y: number } | null>(null);
|
|
66
|
+
const firedAtRef = useRef(0);
|
|
67
|
+
/** The latest callback, so a re-render never leaves a stale one armed. */
|
|
68
|
+
const handlerRef = useRef(onLongPress);
|
|
69
|
+
handlerRef.current = onLongPress;
|
|
70
|
+
|
|
71
|
+
const cancel = useCallback(() => {
|
|
72
|
+
if (timerRef.current !== null) {
|
|
73
|
+
clearTimeout(timerRef.current);
|
|
74
|
+
timerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
originRef.current = null;
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
useEffect(() => cancel, [cancel]);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
onTouchStart: (event) => {
|
|
83
|
+
if (!enabled) return;
|
|
84
|
+
const touch = event.touches[0];
|
|
85
|
+
if (!touch) return;
|
|
86
|
+
cancel();
|
|
87
|
+
originRef.current = { x: touch.clientX, y: touch.clientY };
|
|
88
|
+
timerRef.current = setTimeout(() => {
|
|
89
|
+
timerRef.current = null;
|
|
90
|
+
const origin = originRef.current;
|
|
91
|
+
originRef.current = null;
|
|
92
|
+
if (!origin) return;
|
|
93
|
+
firedAtRef.current = Date.now();
|
|
94
|
+
handlerRef.current(origin.x, origin.y);
|
|
95
|
+
}, LONG_PRESS_MS);
|
|
96
|
+
},
|
|
97
|
+
onTouchMove: (event) => {
|
|
98
|
+
const origin = originRef.current;
|
|
99
|
+
const touch = event.touches[0];
|
|
100
|
+
if (!origin || !touch) return;
|
|
101
|
+
if (Math.abs(touch.clientX - origin.x) > SLOP_PX || Math.abs(touch.clientY - origin.y) > SLOP_PX) cancel();
|
|
102
|
+
},
|
|
103
|
+
onTouchEnd: cancel,
|
|
104
|
+
onTouchCancel: cancel,
|
|
105
|
+
consume: () => {
|
|
106
|
+
if (firedAtRef.current === 0) return false;
|
|
107
|
+
const recent = Date.now() - firedAtRef.current < CLICK_SUPPRESS_MS;
|
|
108
|
+
firedAtRef.current = 0;
|
|
109
|
+
return recent;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The desktop layout's settings modal: whether it is open, which category it
|
|
8
|
+
* opens on, and whether a provider dialog follows.
|
|
9
|
+
*
|
|
10
|
+
* A hook because two different triggers drive it — the sidebar footer's button
|
|
11
|
+
* and the `omp:open-settings` window event (dispatched by the model dropdown,
|
|
12
|
+
* the phone's footer and any plugin) — and the event carries the category it
|
|
13
|
+
* wants. Stated once, the two paths cannot open the same modal on different
|
|
14
|
+
* screens.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState } from 'preact/hooks';
|
|
18
|
+
import { useKabmoEvent } from '@/client/hooks/ui/window-event';
|
|
19
|
+
import type { SettingsCategoryId } from '@/shared/types';
|
|
20
|
+
|
|
21
|
+
export interface SettingsModalState {
|
|
22
|
+
isOpen: boolean;
|
|
23
|
+
category: SettingsCategoryId;
|
|
24
|
+
/** The "add provider" dialog opens on top of the modal's Providers screen. */
|
|
25
|
+
autoOpenAddProvider: boolean;
|
|
26
|
+
open: (category?: SettingsCategoryId, autoOpenAdd?: boolean) => void;
|
|
27
|
+
close: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useSettingsModal(): SettingsModalState {
|
|
31
|
+
const [isOpen, setOpen] = useState(false);
|
|
32
|
+
const [category, setCategory] = useState<SettingsCategoryId>('appearance');
|
|
33
|
+
const [autoOpenAddProvider, setAutoOpenAddProvider] = useState(false);
|
|
34
|
+
|
|
35
|
+
const open = (next?: SettingsCategoryId, autoOpenAdd = false) => {
|
|
36
|
+
setCategory(next ?? 'appearance');
|
|
37
|
+
setAutoOpenAddProvider(autoOpenAdd);
|
|
38
|
+
setOpen(true);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
useKabmoEvent('omp:open-settings', (event) => {
|
|
42
|
+
const detail = (event as CustomEvent<{ category?: SettingsCategoryId; autoOpenAdd?: boolean }>).detail;
|
|
43
|
+
open(detail?.category, Boolean(detail?.autoOpenAdd));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
isOpen,
|
|
48
|
+
category,
|
|
49
|
+
autoOpenAddProvider,
|
|
50
|
+
open,
|
|
51
|
+
close: () => {
|
|
52
|
+
setOpen(false);
|
|
53
|
+
setAutoOpenAddProvider(false);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
import { describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
desktopShellFromIdentity,
|
|
9
10
|
parseShellRelease,
|
|
10
11
|
shellDownloadLabel,
|
|
12
|
+
shellDownloadShortLabel,
|
|
11
13
|
shellFromBootstrap,
|
|
12
14
|
shellReleasePath,
|
|
13
15
|
shouldOfferShellDownload,
|
|
@@ -19,6 +21,7 @@ const RELEASE = {
|
|
|
19
21
|
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/Kabmo-1.2.3-android.apk',
|
|
20
22
|
latest: '1.2.3',
|
|
21
23
|
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
|
|
24
|
+
direct: true,
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
const UPDATE: ShellDownload = { ...RELEASE, kind: 'update', platform: 'android' };
|
|
@@ -155,3 +158,75 @@ describe('shellReleasePath', () => {
|
|
|
155
158
|
expect(shellReleasePath('ios')).toBe('/api/updates/ios');
|
|
156
159
|
});
|
|
157
160
|
});
|
|
161
|
+
|
|
162
|
+
describe('the desktop shells', () => {
|
|
163
|
+
test('each desktop shell reads its own route, with an explicit kind', () => {
|
|
164
|
+
// ONE route with a required `kind`, so no absence can silently mean a
|
|
165
|
+
// platform — the reason the mobile pair has two routes.
|
|
166
|
+
expect(shellReleasePath('macos')).toBe('/api/updates/desktop?kind=macos');
|
|
167
|
+
expect(shellReleasePath('windows')).toBe('/api/updates/desktop?kind=windows');
|
|
168
|
+
expect(shellReleasePath('linux')).toBe('/api/updates/desktop?kind=linux');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('a desktop shell offers an update when it is behind', () => {
|
|
172
|
+
// The reported gap: an installed desktop app that is behind had no way to
|
|
173
|
+
// learn it, because nothing in the console read its version.
|
|
174
|
+
expect(shouldOfferShellDownload('macos', '0.1.0', null, RELEASE)).toEqual({
|
|
175
|
+
...RELEASE,
|
|
176
|
+
kind: 'update',
|
|
177
|
+
platform: 'macos',
|
|
178
|
+
});
|
|
179
|
+
expect(shouldOfferShellDownload('windows', '0.1.0', null, RELEASE)?.platform).toBe('windows');
|
|
180
|
+
expect(shouldOfferShellDownload('linux', '0.1.0', null, RELEASE)?.platform).toBe('linux');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a desktop shell that is current, or ahead, offers nothing', () => {
|
|
184
|
+
expect(shouldOfferShellDownload('macos', '1.2.3', null, RELEASE)).toBeNull();
|
|
185
|
+
expect(shouldOfferShellDownload('macos', '1.3.0', null, RELEASE)).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a desktop BROWSER is offered nothing', () => {
|
|
189
|
+
// A desktop app is installed by hand from the release page; a tab that
|
|
190
|
+
// advertised one would be a button for a product the reader did not ask for.
|
|
191
|
+
expect(shouldOfferShellDownload('macos', null, false, RELEASE)).toBeNull();
|
|
192
|
+
expect(shouldOfferShellDownload('windows', undefined, undefined, RELEASE)).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the label names the desktop platform', () => {
|
|
196
|
+
const update: ShellDownload = { ...RELEASE, kind: 'update', platform: 'linux' };
|
|
197
|
+
expect(shellDownloadLabel(update)).toBe('Update Kabmo 1.2.3 for Linux');
|
|
198
|
+
expect(shellDownloadShortLabel(update)).toBe('Update Linux');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('the short label keeps the mobile spellings', () => {
|
|
202
|
+
expect(shellDownloadShortLabel(UPDATE)).toBe('Update APK');
|
|
203
|
+
expect(shellDownloadShortLabel(INSTALL)).toBe('Get APK');
|
|
204
|
+
expect(shellDownloadShortLabel({ ...UPDATE, platform: 'ios' })).toBe('Update app');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('desktopShellFromIdentity', () => {
|
|
209
|
+
test('reads the bridge identity the shell injects', () => {
|
|
210
|
+
expect(desktopShellFromIdentity({ platform: 'macos', shellVersion: '0.2.0' })).toEqual({
|
|
211
|
+
platform: 'macos',
|
|
212
|
+
version: '0.2.0',
|
|
213
|
+
arch: null,
|
|
214
|
+
});
|
|
215
|
+
expect(desktopShellFromIdentity({ platform: 'windows', shellVersion: '0.2.0' })?.platform).toBe('windows');
|
|
216
|
+
expect(desktopShellFromIdentity({ platform: 'linux', shellVersion: '0.2.0' })?.platform).toBe('linux');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('is null in a browser, and for an identity with no version', () => {
|
|
220
|
+
expect(desktopShellFromIdentity(null)).toBeNull();
|
|
221
|
+
expect(desktopShellFromIdentity(undefined)).toBeNull();
|
|
222
|
+
expect(desktopShellFromIdentity({ platform: 'macos' })).toBeNull();
|
|
223
|
+
expect(desktopShellFromIdentity({ shellVersion: '0.2.0' })).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('an unknown platform is not compared against a published artifact', () => {
|
|
227
|
+
// A newer shell reporting a fourth platform must not be matched to a file
|
|
228
|
+
// nobody publishes.
|
|
229
|
+
expect(desktopShellFromIdentity({ platform: 'beos', shellVersion: '1.0.0' })).toBeNull();
|
|
230
|
+
expect(desktopShellFromIdentity({ platform: 'android', shellVersion: '1.0.0' })).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
});
|