ompchamber 1.2.1 → 2.0.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 +131 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.33d6779f98.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +124 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/editor/index.tsx +11 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -1,92 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The single writer for the document's theme.
|
|
8
|
+
*
|
|
9
|
+
* `data-theme` (the palette id) and `data-theme-variant` (light/dark) are
|
|
10
|
+
* written together, and both are read from the catalog: the variant used to be
|
|
11
|
+
* a hardcoded id list here, in the Shiki selector, in the mermaid hydrator and
|
|
12
|
+
* in the SSR shell — four copies that a new theme would have silently missed.
|
|
13
|
+
*
|
|
14
|
+
* The `data-theme` attribute is the durable copy of the palette; the
|
|
15
|
+
* `omp:theme-changed` event is the notification. CSS reads the attribute, and
|
|
16
|
+
* the mermaid hydrator observes it, so a write that skips the event still
|
|
17
|
+
* recolors — but a write that skips the attribute does not.
|
|
18
|
+
*/
|
|
19
|
+
|
|
1
20
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
2
21
|
import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
|
|
22
|
+
import { DEFAULT_THEME_ID, resolveTheme } from '@/shared/lib/theme/catalog';
|
|
23
|
+
import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
|
|
3
24
|
|
|
4
|
-
/** Broadcast on every theme write. Detail is the new theme id
|
|
5
|
-
* `data-theme` attribute on `<html>` is the durable copy of the same fact. */
|
|
25
|
+
/** Broadcast on every theme write. Detail is the new theme id. */
|
|
6
26
|
export const THEME_CHANGED_EVENT = 'omp:theme-changed';
|
|
7
27
|
|
|
28
|
+
/** The palette id currently on `<html>`, falling back to the shipped default. */
|
|
29
|
+
export function currentThemeId(): string {
|
|
30
|
+
if (typeof document === 'undefined') return DEFAULT_THEME_ID;
|
|
31
|
+
return document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme') || DEFAULT_THEME_ID;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Put the catalog on the document before the first render.
|
|
36
|
+
*
|
|
37
|
+
* The SSR shell already inlines the stylesheet and both attributes; this is the
|
|
38
|
+
* guard for a document that predates them — a cached page, or `dist/client/
|
|
39
|
+
* index.html` left over from an older build while only the server was restarted.
|
|
40
|
+
* Without it the page keeps `:root`'s default palette and no variant attribute,
|
|
41
|
+
* which is a wrong palette, not merely a stale one.
|
|
42
|
+
*
|
|
43
|
+
* Called once from `main.tsx` rather than from a hook: the only components that
|
|
44
|
+
* read the theme are lazily mounted panels, so a hook would not run until the
|
|
45
|
+
* user opened one.
|
|
46
|
+
*/
|
|
47
|
+
export function initDocumentTheme(): void {
|
|
48
|
+
if (typeof document === 'undefined') return;
|
|
49
|
+
if (!document.getElementById(THEME_STYLE_ELEMENT_ID)) {
|
|
50
|
+
const style = document.createElement('style');
|
|
51
|
+
style.id = THEME_STYLE_ELEMENT_ID;
|
|
52
|
+
style.textContent = themeStyleSheet();
|
|
53
|
+
document.head.appendChild(style);
|
|
54
|
+
}
|
|
55
|
+
applyDocumentTheme(currentThemeId());
|
|
56
|
+
}
|
|
57
|
+
|
|
8
58
|
/**
|
|
9
59
|
* Point the document at `theme` and announce it.
|
|
10
60
|
*
|
|
11
|
-
* The single writer path for the theme: the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* previous
|
|
15
|
-
* watch the attribute too, so this stays correct even if the event is missed.
|
|
61
|
+
* The single writer path for the theme: the settings modal and every future
|
|
62
|
+
* picker call this, because a writer that only sets `data-theme` leaves runtime
|
|
63
|
+
* consumers (the mermaid hydrator re-renders diagrams per palette) on the
|
|
64
|
+
* previous colors until the next reload.
|
|
16
65
|
*/
|
|
17
66
|
export function applyDocumentTheme(theme: string): void {
|
|
18
67
|
if (typeof document === 'undefined') return;
|
|
19
|
-
|
|
20
|
-
|
|
68
|
+
const palette = resolveTheme(theme);
|
|
69
|
+
const root = document.documentElement;
|
|
70
|
+
root.dataset.theme = palette.id;
|
|
71
|
+
root.dataset.themeVariant = palette.variant;
|
|
72
|
+
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', palette.canvas);
|
|
73
|
+
window.dispatchEvent(new CustomEvent(THEME_CHANGED_EVENT, { detail: palette.id }));
|
|
21
74
|
}
|
|
22
75
|
|
|
23
76
|
export function useTheme() {
|
|
24
77
|
const writeChamberSettings = useChamberSettingsWriter();
|
|
25
|
-
const [theme, setThemeState] = useState<string>(
|
|
26
|
-
if (typeof document !== 'undefined') {
|
|
27
|
-
return document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme') || 'paper';
|
|
28
|
-
}
|
|
29
|
-
return 'paper';
|
|
30
|
-
});
|
|
78
|
+
const [theme, setThemeState] = useState<string>(currentThemeId);
|
|
31
79
|
|
|
32
|
-
const isDark = theme === '
|
|
80
|
+
const isDark = resolveTheme(theme).variant === 'dark';
|
|
33
81
|
|
|
34
82
|
useEffect(() => {
|
|
35
83
|
if (typeof document === 'undefined') return;
|
|
36
84
|
|
|
37
|
-
const updateTheme = () => {
|
|
38
|
-
const current = document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme') || 'paper';
|
|
39
|
-
setThemeState(current);
|
|
40
|
-
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
|
41
|
-
if (metaThemeColor) {
|
|
42
|
-
metaThemeColor.setAttribute('content', current === 'one-dark-pro-soft' ? '#282c34' : '#faf8f3');
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
updateTheme();
|
|
47
|
-
|
|
48
85
|
const observer = new MutationObserver((mutations) => {
|
|
49
86
|
for (const mutation of mutations) {
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
(mutation.attributeName === 'data-theme' || mutation.attributeName === 'class')
|
|
53
|
-
) {
|
|
54
|
-
updateTheme();
|
|
87
|
+
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
|
|
88
|
+
setThemeState(currentThemeId());
|
|
55
89
|
}
|
|
56
90
|
}
|
|
57
91
|
});
|
|
92
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
58
93
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const handleCustomChange = (e: Event) => {
|
|
65
|
-
const customEvent = e as CustomEvent<string>;
|
|
66
|
-
if (customEvent.detail) {
|
|
67
|
-
setThemeState(customEvent.detail);
|
|
68
|
-
} else {
|
|
69
|
-
updateTheme();
|
|
70
|
-
}
|
|
94
|
+
const handleCustomChange = (event: Event) => {
|
|
95
|
+
const detail = (event as CustomEvent<string>).detail;
|
|
96
|
+
setThemeState(detail ? resolveTheme(detail).id : currentThemeId());
|
|
71
97
|
};
|
|
72
|
-
|
|
73
|
-
window.addEventListener('omp:theme-changed', handleCustomChange);
|
|
98
|
+
window.addEventListener(THEME_CHANGED_EVENT, handleCustomChange);
|
|
74
99
|
|
|
75
100
|
return () => {
|
|
76
101
|
observer.disconnect();
|
|
77
|
-
window.removeEventListener(
|
|
102
|
+
window.removeEventListener(THEME_CHANGED_EVENT, handleCustomChange);
|
|
78
103
|
};
|
|
79
104
|
}, []);
|
|
80
105
|
|
|
81
|
-
const setTheme = useCallback(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
const setTheme = useCallback(
|
|
107
|
+
(newTheme: string) => {
|
|
108
|
+
const id = resolveTheme(newTheme).id;
|
|
109
|
+
applyDocumentTheme(id);
|
|
110
|
+
setThemeState(id);
|
|
111
|
+
writeChamberSettings({ theme: id });
|
|
112
|
+
},
|
|
113
|
+
[writeChamberSettings],
|
|
114
|
+
);
|
|
90
115
|
|
|
91
|
-
return { theme, isDark, setTheme
|
|
116
|
+
return { theme, isDark, setTheme };
|
|
92
117
|
}
|
|
@@ -93,7 +93,7 @@ export function useFileTabs(
|
|
|
93
93
|
|
|
94
94
|
// Global listeners for omp:open-file and omp:open-diff
|
|
95
95
|
useChamberEvent('omp:open-file', (e) => {
|
|
96
|
-
const customEvent = e as CustomEvent<{ path: string; name?: string; id?: number; content?: string }>;
|
|
96
|
+
const customEvent = e as CustomEvent<{ path: string; name?: string; id?: number; content?: string; root?: string; repo?: string }>;
|
|
97
97
|
if (!customEvent.detail || !customEvent.detail.path) return;
|
|
98
98
|
|
|
99
99
|
const rawPath = customEvent.detail.path.replace(/^\/+/, '');
|
|
@@ -110,6 +110,10 @@ export function useFileTabs(
|
|
|
110
110
|
name,
|
|
111
111
|
path: rawPath,
|
|
112
112
|
content: customEvent.detail.content,
|
|
113
|
+
// A caller that knows its own scope (a preview's relative link) keeps it;
|
|
114
|
+
// otherwise the file belongs to the active project.
|
|
115
|
+
root: customEvent.detail.root,
|
|
116
|
+
repo: customEvent.detail.repo,
|
|
113
117
|
});
|
|
114
118
|
});
|
|
115
119
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nested-repo scope for the right-panel views (files, search, git, terminal).
|
|
8
|
+
*
|
|
9
|
+
* Every one of them needs the same two things, and both are meaningless without
|
|
10
|
+
* the workspace root they describe:
|
|
11
|
+
*
|
|
12
|
+
* - WHICH repo the view operates on (`useRepoScope`). A repo is a path
|
|
13
|
+
* relative to the active root — `projects/jns6_5/token` only names a
|
|
14
|
+
* directory under `~/JatisMobile/Workspace` — so a choice is only valid
|
|
15
|
+
* next to the root it was made under.
|
|
16
|
+
* - WHAT repos exist under that root (`useRepoList`), discovered server-side
|
|
17
|
+
* and stored with the root it was discovered for (see `./store`).
|
|
18
|
+
*
|
|
19
|
+
* Kept as bare per-panel values, both outlived the workspace they belonged to:
|
|
20
|
+
* after a workspace switch the picker still listed the previous workspace's
|
|
21
|
+
* repos, still showed one of them as the selection, and every request the panel
|
|
22
|
+
* made named a path that did not exist under the new root. Pairing each value
|
|
23
|
+
* with its root invalidates it by construction — no reset effect to order
|
|
24
|
+
* against the per-session state restore — while a session returned to inside
|
|
25
|
+
* the same workspace keeps the repo it was left on.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useCallback, useRef, useSyncExternalStore } from 'preact/compat';
|
|
29
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
30
|
+
import { repoStore, UNKNOWN_LIST } from '@/client/hooks/workspace/repo-scope/store';
|
|
31
|
+
|
|
32
|
+
/** A repo choice, kept next to the workspace root it was made under. */
|
|
33
|
+
interface RepoPick {
|
|
34
|
+
root: string;
|
|
35
|
+
repo: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The repo a git panel works on: the user's pick, or — when they have none and
|
|
40
|
+
* the workspace root is not itself a repo — the first repo discovery found.
|
|
41
|
+
*
|
|
42
|
+
* The git loader applies the same fallback when it is sent no repo at all
|
|
43
|
+
* (`repo = repos.includes('.') ? '.' : repos[0]`), so resolving it here is what
|
|
44
|
+
* keeps the header label, the picker's checkmark and the requests all naming
|
|
45
|
+
* the repo git is actually operating on. Only git resolves it: browsing the
|
|
46
|
+
* workspace root is legitimate even when that root is not a repo — a folder of
|
|
47
|
+
* nested repos is a normal workspace — so the files, search and terminal panels
|
|
48
|
+
* keep `'.'` as the root itself.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveRepoForPanel(pick: string, repos: readonly string[]): string {
|
|
51
|
+
if (pick !== '.' || repos.includes('.')) return pick;
|
|
52
|
+
return repos[0] ?? '.';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The repo this panel operates on, restored per session but dropped the moment
|
|
57
|
+
* the active workspace root changes.
|
|
58
|
+
*/
|
|
59
|
+
export function useRepoScope(
|
|
60
|
+
rootPath: string | undefined,
|
|
61
|
+
stateKey: string,
|
|
62
|
+
): { activeRepo: string; setActiveRepo: (repo: string) => void; ready: boolean } {
|
|
63
|
+
const [pick, setPick, ready] = useSessionState<RepoPick | null>(stateKey, null);
|
|
64
|
+
const scope = rootPath ?? '';
|
|
65
|
+
|
|
66
|
+
// Read through refs so the setter keeps ONE identity: it is handed to child
|
|
67
|
+
// components and used in effect dependencies, and `useSessionState` mints a
|
|
68
|
+
// fresh setter per render.
|
|
69
|
+
const scopeRef = useRef(scope);
|
|
70
|
+
scopeRef.current = scope;
|
|
71
|
+
const setPickRef = useRef(setPick);
|
|
72
|
+
setPickRef.current = setPick;
|
|
73
|
+
const setActiveRepo = useCallback((repo: string) => {
|
|
74
|
+
setPickRef.current({ root: scopeRef.current, repo });
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// A pick made under another root (or stored before the root was recorded
|
|
78
|
+
// alongside it) names a directory that need not exist here: fall back to the
|
|
79
|
+
// workspace root.
|
|
80
|
+
const activeRepo =
|
|
81
|
+
pick && typeof pick === 'object' && pick.root === scope && typeof pick.repo === 'string' && pick.repo
|
|
82
|
+
? pick.repo
|
|
83
|
+
: '.';
|
|
84
|
+
|
|
85
|
+
return { activeRepo, setActiveRepo, ready };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RepoListHandle {
|
|
89
|
+
/** Repos of the active root; the workspace root alone until discovery answers. */
|
|
90
|
+
repos: string[];
|
|
91
|
+
/** Discovery is still running — the server said so, or a rescan is in flight. */
|
|
92
|
+
scanning: boolean;
|
|
93
|
+
/** Re-run discovery from scratch (the picker's refresh button). */
|
|
94
|
+
rescan: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Discovered repos of the active root.
|
|
99
|
+
*
|
|
100
|
+
* @param active - when false nothing is subscribed; a hidden panel has no use
|
|
101
|
+
* for a list that costs the server a `find` over the workspace.
|
|
102
|
+
*/
|
|
103
|
+
export function useRepoList(rootPath: string | undefined, active: boolean): RepoListHandle {
|
|
104
|
+
const scope = rootPath ?? '';
|
|
105
|
+
const subscribe = useCallback(
|
|
106
|
+
(listener: () => void) => (active ? repoStore.subscribe(scope, listener) : () => {}),
|
|
107
|
+
[scope, active],
|
|
108
|
+
);
|
|
109
|
+
const getSnapshot = useCallback(
|
|
110
|
+
() => (active ? repoStore.snapshot(scope) : UNKNOWN_LIST),
|
|
111
|
+
[scope, active],
|
|
112
|
+
);
|
|
113
|
+
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
|
114
|
+
const rescan = useCallback(() => {
|
|
115
|
+
if (active) repoStore.rescan(scope);
|
|
116
|
+
}, [scope, active]);
|
|
117
|
+
|
|
118
|
+
return { repos: snapshot.repos, scanning: snapshot.scanning, rescan };
|
|
119
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { createRepoStore, type RepoFetch, type RepoScheduler } from '@/client/hooks/workspace/repo-scope/store';
|
|
9
|
+
|
|
10
|
+
/** Resolves each request from a per-root table, recording the roots asked for. */
|
|
11
|
+
function fakeFetch(byRoot: Record<string, { repos: string[]; reposPending?: boolean }>) {
|
|
12
|
+
const requested: string[] = [];
|
|
13
|
+
const fetchImpl: RepoFetch = async (url) => {
|
|
14
|
+
const root = new URL(url, 'http://localhost').searchParams.get('root') ?? '';
|
|
15
|
+
requested.push(root);
|
|
16
|
+
return byRoot[root] ?? { repos: ['.'] };
|
|
17
|
+
};
|
|
18
|
+
return { fetchImpl, requested };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Retries the caller arms, so a test can fire them instead of waiting them out. */
|
|
22
|
+
function fakeScheduler() {
|
|
23
|
+
const armed: (() => void)[] = [];
|
|
24
|
+
const scheduler: RepoScheduler = (run) => {
|
|
25
|
+
armed.push(run);
|
|
26
|
+
return () => {
|
|
27
|
+
const index = armed.indexOf(run);
|
|
28
|
+
if (index >= 0) armed.splice(index, 1);
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
return { scheduler, armed };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('repo list invalidation on workspace switch', () => {
|
|
35
|
+
test('a new root never reports the previous root\'s repos', async () => {
|
|
36
|
+
const { fetchImpl } = fakeFetch({
|
|
37
|
+
'/ws/a': { repos: ['.', 'projects/a'] },
|
|
38
|
+
'/ws/b': { repos: ['.', 'projects/b'] },
|
|
39
|
+
});
|
|
40
|
+
const store = createRepoStore({ fetch: fetchImpl });
|
|
41
|
+
|
|
42
|
+
const seenA: string[][] = [];
|
|
43
|
+
const unsubscribeA = store.subscribe('/ws/a', () => seenA.push(store.snapshot('/ws/a').repos));
|
|
44
|
+
await store.settled('/ws/a');
|
|
45
|
+
expect(store.snapshot('/ws/a').repos).toEqual(['.', 'projects/a']);
|
|
46
|
+
|
|
47
|
+
// Switching workspaces: root B has no entry, so it starts from the root
|
|
48
|
+
// alone — not from A's list.
|
|
49
|
+
expect(store.snapshot('/ws/b').repos).toEqual(['.']);
|
|
50
|
+
unsubscribeA();
|
|
51
|
+
|
|
52
|
+
const seenB: string[][] = [];
|
|
53
|
+
const unsubscribeB = store.subscribe('/ws/b', () => seenB.push(store.snapshot('/ws/b').repos));
|
|
54
|
+
await store.settled('/ws/b');
|
|
55
|
+
expect(store.snapshot('/ws/b').repos).toEqual(['.', 'projects/b']);
|
|
56
|
+
// Every notification for B reported B's list — A's list was never published
|
|
57
|
+
// under B, not even for the render before the read landed.
|
|
58
|
+
expect(seenB).toEqual([['.'], ['.', 'projects/b']]);
|
|
59
|
+
expect(seenA).toEqual([['.'], ['.', 'projects/a']]);
|
|
60
|
+
unsubscribeB();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a list already discovered for a root is reused without re-asking', async () => {
|
|
64
|
+
const { fetchImpl, requested } = fakeFetch({ '/ws/a': { repos: ['.', 'projects/a'] } });
|
|
65
|
+
const store = createRepoStore({ fetch: fetchImpl });
|
|
66
|
+
|
|
67
|
+
const unsubscribeFirst = store.subscribe('/ws/a', () => {});
|
|
68
|
+
await store.settled('/ws/a');
|
|
69
|
+
unsubscribeFirst();
|
|
70
|
+
expect(requested).toEqual(['/ws/a']);
|
|
71
|
+
|
|
72
|
+
// A right-panel view switch re-subscribes to the same root.
|
|
73
|
+
const unsubscribeSecond = store.subscribe('/ws/a', () => {});
|
|
74
|
+
await store.settled('/ws/a');
|
|
75
|
+
expect(requested).toEqual(['/ws/a']);
|
|
76
|
+
expect(store.snapshot('/ws/a').repos).toEqual(['.', 'projects/a']);
|
|
77
|
+
unsubscribeSecond();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an unsettled discovery is resumed, not abandoned, when a subscriber returns', async () => {
|
|
81
|
+
const { fetchImpl, requested } = fakeFetch({ '/ws/a': { repos: ['.'], reposPending: true } });
|
|
82
|
+
const { scheduler, armed } = fakeScheduler();
|
|
83
|
+
const store = createRepoStore({ fetch: fetchImpl, scheduler });
|
|
84
|
+
|
|
85
|
+
const unsubscribe = store.subscribe('/ws/a', () => {});
|
|
86
|
+
await store.settled('/ws/a');
|
|
87
|
+
expect(store.snapshot('/ws/a').scanning).toBe(true);
|
|
88
|
+
expect(armed).toHaveLength(1);
|
|
89
|
+
unsubscribe();
|
|
90
|
+
// The retry is dropped with the last subscriber rather than left armed.
|
|
91
|
+
expect(armed).toHaveLength(0);
|
|
92
|
+
|
|
93
|
+
// Re-subscribing resumes the scan instead of inheriting a spinner that
|
|
94
|
+
// nothing drives.
|
|
95
|
+
const unsubscribeAgain = store.subscribe('/ws/a', () => {});
|
|
96
|
+
await store.settled('/ws/a');
|
|
97
|
+
expect(requested).toEqual(['/ws/a', '/ws/a']);
|
|
98
|
+
expect(armed).toHaveLength(1);
|
|
99
|
+
unsubscribeAgain();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a failed discovery leaves the root unlisted so the next subscriber retries', async () => {
|
|
103
|
+
let calls = 0;
|
|
104
|
+
const store = createRepoStore({
|
|
105
|
+
fetch: async () => {
|
|
106
|
+
calls += 1;
|
|
107
|
+
if (calls === 1) throw new Error('offline');
|
|
108
|
+
return { repos: ['.', 'projects/a'] };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const unsubscribe = store.subscribe('/ws/a', () => {});
|
|
113
|
+
await store.settled('/ws/a');
|
|
114
|
+
expect(store.snapshot('/ws/a').repos).toEqual(['.']);
|
|
115
|
+
unsubscribe();
|
|
116
|
+
|
|
117
|
+
const unsubscribeAgain = store.subscribe('/ws/a', () => {});
|
|
118
|
+
await store.settled('/ws/a');
|
|
119
|
+
expect(calls).toBe(2);
|
|
120
|
+
expect(store.snapshot('/ws/a').repos).toEqual(['.', 'projects/a']);
|
|
121
|
+
unsubscribeAgain();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a response that lands after its root lost every subscriber is dropped', async () => {
|
|
125
|
+
const gate = Promise.withResolvers<unknown>();
|
|
126
|
+
const store = createRepoStore({ fetch: () => gate.promise });
|
|
127
|
+
|
|
128
|
+
const unsubscribe = store.subscribe('/ws/a', () => {});
|
|
129
|
+
unsubscribe();
|
|
130
|
+
gate.resolve({ repos: ['.', 'projects/a'] });
|
|
131
|
+
await store.settled('/ws/a');
|
|
132
|
+
|
|
133
|
+
// The read was abandoned with the subscription: nothing is reported for a
|
|
134
|
+
// root nobody is showing.
|
|
135
|
+
expect(store.snapshot('/ws/a').repos).toEqual(['.']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a rescan reports the fresh list and clears the pending flag', async () => {
|
|
139
|
+
let repos = ['.'];
|
|
140
|
+
const requested: string[] = [];
|
|
141
|
+
const store = createRepoStore({
|
|
142
|
+
fetch: async (url) => {
|
|
143
|
+
requested.push(new URL(url, 'http://localhost').searchParams.get('rescan') ?? '');
|
|
144
|
+
return { repos };
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const unsubscribe = store.subscribe('/ws/a', () => {});
|
|
149
|
+
await store.settled('/ws/a');
|
|
150
|
+
expect(store.snapshot('/ws/a')).toEqual({ repos: ['.'], scanning: false });
|
|
151
|
+
|
|
152
|
+
// A nested repo that appeared after the first discovery.
|
|
153
|
+
repos = ['.', 'projects/late'];
|
|
154
|
+
store.rescan('/ws/a');
|
|
155
|
+
await store.settled('/ws/a');
|
|
156
|
+
|
|
157
|
+
expect(requested).toEqual(['', '1']);
|
|
158
|
+
expect(store.snapshot('/ws/a')).toEqual({ repos: ['.', 'projects/late'], scanning: false });
|
|
159
|
+
unsubscribe();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nested-repo discovery store, keyed by workspace root.
|
|
8
|
+
*
|
|
9
|
+
* The leak this module exists to prevent: a repo is a path RELATIVE to a root
|
|
10
|
+
* (`projects/jns6_5/token` only names a directory under `~/JatisMobile/Workspace`),
|
|
11
|
+
* so a list discovered for one workspace is meaningless under another. Keying
|
|
12
|
+
* every entry by the root it was discovered for makes that structural — a new
|
|
13
|
+
* root has no entry and starts from {@link UNKNOWN_LIST} instead of inheriting
|
|
14
|
+
* the previous list.
|
|
15
|
+
*
|
|
16
|
+
* A store rather than a fetch per hook: the right-panel views ask the same
|
|
17
|
+
* question about the same root, and independent copies meant one request per
|
|
18
|
+
* panel per poll interval plus lists that could disagree.
|
|
19
|
+
*
|
|
20
|
+
* Both platform dependencies are injected — `fetch` and the retry scheduler —
|
|
21
|
+
* so the invalidation rules are testable without a DOM and without wall-clock
|
|
22
|
+
* timers.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { REPO_DISCOVERY_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
26
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
27
|
+
|
|
28
|
+
export interface RepoListSnapshot {
|
|
29
|
+
repos: string[];
|
|
30
|
+
/** Discovery is still running server-side, or a forced rescan is. */
|
|
31
|
+
scanning: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The list before discovery has answered: the workspace root alone. */
|
|
35
|
+
export const UNKNOWN_LIST: RepoListSnapshot = { repos: ['.'], scanning: false };
|
|
36
|
+
|
|
37
|
+
/** `fetch` seam: resolves the parsed `?reposOnly=1` payload, rejects on error. */
|
|
38
|
+
export type RepoFetch = (url: string) => Promise<unknown>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Retry seam. Runs `run` once after `delayMs` and returns a cancel; the default
|
|
42
|
+
* is a one-shot timer, and the caller re-arms it per attempt.
|
|
43
|
+
*/
|
|
44
|
+
export type RepoScheduler = (run: () => void, delayMs: number) => () => void;
|
|
45
|
+
|
|
46
|
+
interface RootEntry {
|
|
47
|
+
snapshot: RepoListSnapshot;
|
|
48
|
+
/** True once a discovery response was accepted for this root. Drives the
|
|
49
|
+
* "start discovery on first subscriber" decision — object identity of
|
|
50
|
+
* `snapshot` cannot, because clearing the spinner mints a new one. */
|
|
51
|
+
loaded: boolean;
|
|
52
|
+
listeners: Set<() => void>;
|
|
53
|
+
controller: AbortController | null;
|
|
54
|
+
cancelRetry: (() => void) | null;
|
|
55
|
+
/** The discovery currently in flight, so a caller can await its settlement. */
|
|
56
|
+
pending: Promise<void> | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RepoStore {
|
|
60
|
+
/** Repos discovered for `root`; {@link UNKNOWN_LIST} until one is. */
|
|
61
|
+
snapshot: (root: string) => RepoListSnapshot;
|
|
62
|
+
/** Await the discovery in flight for `root` (a no-op when there is none). */
|
|
63
|
+
settled: (root: string) => Promise<void>;
|
|
64
|
+
/** Observe `root`; the first subscriber starts discovery. Returns a cancel. */
|
|
65
|
+
subscribe: (root: string, listener: () => void) => () => void;
|
|
66
|
+
/** Re-run discovery for `root` from scratch, ignoring any cached result. */
|
|
67
|
+
rescan: (root: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RepoStoreOptions {
|
|
71
|
+
fetch: RepoFetch;
|
|
72
|
+
scheduler?: RepoScheduler;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const defaultScheduler: RepoScheduler = (run, delayMs) => {
|
|
76
|
+
const id = setTimeout(run, delayMs);
|
|
77
|
+
return () => clearTimeout(id);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function createRepoStore({ fetch: fetchImpl, scheduler = defaultScheduler }: RepoStoreOptions): RepoStore {
|
|
81
|
+
// Per-store, not module-level: two stores must not share state, and a test
|
|
82
|
+
// store must not see the app store's roots.
|
|
83
|
+
const entries = new Map<string, RootEntry>();
|
|
84
|
+
|
|
85
|
+
function entryFor(root: string): RootEntry {
|
|
86
|
+
let entry = entries.get(root);
|
|
87
|
+
if (!entry) {
|
|
88
|
+
entry = {
|
|
89
|
+
snapshot: UNKNOWN_LIST,
|
|
90
|
+
loaded: false,
|
|
91
|
+
listeners: new Set(),
|
|
92
|
+
controller: null,
|
|
93
|
+
cancelRetry: null,
|
|
94
|
+
pending: null,
|
|
95
|
+
};
|
|
96
|
+
entries.set(root, entry);
|
|
97
|
+
}
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publish(root: string, patch: Partial<RepoListSnapshot>): void {
|
|
102
|
+
const entry = entries.get(root);
|
|
103
|
+
if (!entry) return;
|
|
104
|
+
const next = { ...entry.snapshot, ...patch };
|
|
105
|
+
if (next.repos === entry.snapshot.repos && next.scanning === entry.snapshot.scanning) return;
|
|
106
|
+
entry.snapshot = next;
|
|
107
|
+
for (const listener of entry.listeners) listener();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cancelRetry(entry: RootEntry): void {
|
|
111
|
+
entry.cancelRetry?.();
|
|
112
|
+
entry.cancelRetry = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function load(root: string, rescan: boolean): Promise<void> {
|
|
116
|
+
const entry = entryFor(root);
|
|
117
|
+
// A newer load supersedes this one: its response must not overwrite what
|
|
118
|
+
// the newer request is already about to replace.
|
|
119
|
+
entry.controller?.abort();
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
entry.controller = controller;
|
|
122
|
+
publish(root, { scanning: true });
|
|
123
|
+
|
|
124
|
+
const params = new URLSearchParams({ reposOnly: '1' });
|
|
125
|
+
if (root) params.set('root', root);
|
|
126
|
+
if (rescan) params.set('rescan', '1');
|
|
127
|
+
params.set('t', String(Date.now()));
|
|
128
|
+
|
|
129
|
+
const pending = (async () => {
|
|
130
|
+
const data = await fetchImpl(`/api/fs/git?${params.toString()}`).catch(() => null);
|
|
131
|
+
if (entry.controller !== controller) return;
|
|
132
|
+
entry.controller = null;
|
|
133
|
+
|
|
134
|
+
const repos = isRecord(data) && Array.isArray(data.repos) ? data.repos : null;
|
|
135
|
+
if (!repos) {
|
|
136
|
+
// A failed read leaves the snapshot as it was — for a root nobody has
|
|
137
|
+
// successfully listed yet that is still `UNKNOWN_LIST`, so the next
|
|
138
|
+
// subscriber retries instead of sitting on an empty list.
|
|
139
|
+
publish(root, { scanning: false });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const scanning = isRecord(data) && data.reposPending === true;
|
|
143
|
+
entry.loaded = true;
|
|
144
|
+
publish(root, { repos: repos.filter((r): r is string => typeof r === 'string'), scanning });
|
|
145
|
+
|
|
146
|
+
// Discovery runs in the background server-side — the loader returns at
|
|
147
|
+
// once with `reposPending` — so retry until it settles.
|
|
148
|
+
if (scanning) {
|
|
149
|
+
cancelRetry(entry);
|
|
150
|
+
entry.cancelRetry = scheduler(() => {
|
|
151
|
+
entry.cancelRetry = null;
|
|
152
|
+
if (entry.listeners.size === 0) return;
|
|
153
|
+
void load(root, false);
|
|
154
|
+
}, REPO_DISCOVERY_POLL_MS);
|
|
155
|
+
} else {
|
|
156
|
+
cancelRetry(entry);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
|
|
160
|
+
entry.pending = pending;
|
|
161
|
+
return pending;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function subscribe(root: string, listener: () => void): () => void {
|
|
165
|
+
const entry = entryFor(root);
|
|
166
|
+
entry.listeners.add(listener);
|
|
167
|
+
// First subscriber for this root — or the first since everyone left —
|
|
168
|
+
// starts (or resumes) discovery, unless a settled list is already known for
|
|
169
|
+
// this same root: a right-panel view switch must not re-run the server's
|
|
170
|
+
// `find`.
|
|
171
|
+
if (entry.controller === null && entry.cancelRetry === null && (!entry.loaded || entry.snapshot.scanning)) {
|
|
172
|
+
void load(root, false);
|
|
173
|
+
}
|
|
174
|
+
return () => {
|
|
175
|
+
entry.listeners.delete(listener);
|
|
176
|
+
if (entry.listeners.size > 0) return;
|
|
177
|
+
// Nobody is showing this root's list any more: abandon the read in flight
|
|
178
|
+
// and drop the retry. The snapshot keeps its `scanning` flag, so the next
|
|
179
|
+
// subscriber resumes discovery rather than inheriting a spinner that
|
|
180
|
+
// nothing is driving.
|
|
181
|
+
entry.controller?.abort();
|
|
182
|
+
entry.controller = null;
|
|
183
|
+
cancelRetry(entry);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
snapshot: (root) => entries.get(root)?.snapshot ?? UNKNOWN_LIST,
|
|
189
|
+
settled: (root) => entries.get(root)?.pending ?? Promise.resolve(),
|
|
190
|
+
subscribe,
|
|
191
|
+
rescan: (root) => void load(root, true),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The app's single store, over the browser's `fetch` and real timers. */
|
|
196
|
+
export const repoStore = createRepoStore({
|
|
197
|
+
fetch: async (url) => {
|
|
198
|
+
const response = await fetch(url);
|
|
199
|
+
return response.json();
|
|
200
|
+
},
|
|
201
|
+
});
|