@kabmo-labs/kabmo 0.0.1 → 0.2.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 +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { Window } from 'happy-dom';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
BACK_PROPERTY,
|
|
11
|
+
dispatchBack,
|
|
12
|
+
installBackDispatcher,
|
|
13
|
+
registerBackHandler,
|
|
14
|
+
} from '@/client/lib/native/back';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The stack is module state, so every case unregisters what it added. The
|
|
18
|
+
* shell's contract is exactly two facts: last-registered wins, and the first
|
|
19
|
+
* handler that answers `true` stops the walk.
|
|
20
|
+
*/
|
|
21
|
+
const cleanups: Array<() => void> = [];
|
|
22
|
+
|
|
23
|
+
function onBack(handler: () => boolean): () => boolean {
|
|
24
|
+
const remove = registerBackHandler(handler);
|
|
25
|
+
cleanups.push(remove);
|
|
26
|
+
return handler;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
while (cleanups.length > 0) cleanups.pop()!();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('dispatchBack', () => {
|
|
34
|
+
test('reports false when nothing is registered', () => {
|
|
35
|
+
expect(dispatchBack()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('the last registered handler runs first', () => {
|
|
39
|
+
const order: string[] = [];
|
|
40
|
+
onBack(() => {
|
|
41
|
+
order.push('first');
|
|
42
|
+
return false;
|
|
43
|
+
});
|
|
44
|
+
onBack(() => {
|
|
45
|
+
order.push('second');
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(dispatchBack()).toBe(true);
|
|
50
|
+
expect(order).toEqual(['second']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the first true stops the walk', () => {
|
|
54
|
+
const order: string[] = [];
|
|
55
|
+
onBack(() => {
|
|
56
|
+
order.push('inner');
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
onBack(() => {
|
|
60
|
+
order.push('outer');
|
|
61
|
+
return false;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(dispatchBack()).toBe(true);
|
|
65
|
+
expect(order).toEqual(['outer', 'inner']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a throwing handler is skipped, and the next one still gets the press', () => {
|
|
69
|
+
onBack(() => {
|
|
70
|
+
throw new Error('broken handler');
|
|
71
|
+
});
|
|
72
|
+
onBack(() => true);
|
|
73
|
+
|
|
74
|
+
expect(dispatchBack()).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('unregistering removes the handler', () => {
|
|
78
|
+
let calls = 0;
|
|
79
|
+
const remove = registerBackHandler(() => {
|
|
80
|
+
calls += 1;
|
|
81
|
+
return false;
|
|
82
|
+
});
|
|
83
|
+
remove();
|
|
84
|
+
|
|
85
|
+
expect(dispatchBack()).toBe(false);
|
|
86
|
+
expect(calls).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('installBackDispatcher', () => {
|
|
91
|
+
test('exposes window.__kabmoBack and is idempotent', () => {
|
|
92
|
+
const win = new Window({ url: 'http://localhost/' });
|
|
93
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
94
|
+
const previous = target.window;
|
|
95
|
+
target.window = win;
|
|
96
|
+
try {
|
|
97
|
+
installBackDispatcher();
|
|
98
|
+
installBackDispatcher();
|
|
99
|
+
|
|
100
|
+
const installed = (win as unknown as Record<string, unknown>)[BACK_PROPERTY];
|
|
101
|
+
expect(typeof installed).toBe('function');
|
|
102
|
+
expect((installed as () => boolean)()).toBe(false);
|
|
103
|
+
} finally {
|
|
104
|
+
target.window = previous;
|
|
105
|
+
win.close();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Android hardware back button, delegated to the page.
|
|
8
|
+
*
|
|
9
|
+
* The shell asks `window.__kabmoBack()` before it walks WebView history, and
|
|
10
|
+
* that indirection exists because of a real mismatch: Kabmo's phone layout keeps
|
|
11
|
+
* its screens in React state (`MobileLayoutWrapper`'s `currentScreen`), not in
|
|
12
|
+
* the URL. `history.back()` therefore reopens a previous session while leaving
|
|
13
|
+
* the drawer that opened it on screen — the user presses back and the app
|
|
14
|
+
* appears to do something unrelated.
|
|
15
|
+
*
|
|
16
|
+
* Handlers are a STACK, last-registered first: a settings dialog mounted over the
|
|
17
|
+
* layout gets the press before the layout does, which is the order the user sees.
|
|
18
|
+
* Each handler answers whether it consumed the press; the first `true` wins.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type BackHandler = () => boolean;
|
|
22
|
+
|
|
23
|
+
/** The property the shell calls. Named once so both sides agree. */
|
|
24
|
+
export const BACK_PROPERTY = '__kabmoBack';
|
|
25
|
+
|
|
26
|
+
const handlers: BackHandler[] = [];
|
|
27
|
+
|
|
28
|
+
/** Register a handler; the returned function removes it. */
|
|
29
|
+
export function registerBackHandler(handler: BackHandler): () => void {
|
|
30
|
+
handlers.push(handler);
|
|
31
|
+
return () => {
|
|
32
|
+
const index = handlers.indexOf(handler);
|
|
33
|
+
if (index >= 0) handlers.splice(index, 1);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ask the page to consume a back press.
|
|
39
|
+
*
|
|
40
|
+
* Exported for tests and for a caller that wants the same precedence without the
|
|
41
|
+
* native shell (a keyboard shortcut). Reverse order is the whole contract, and a
|
|
42
|
+
* throwing handler is skipped rather than allowed to strand the user in a screen
|
|
43
|
+
* they cannot leave.
|
|
44
|
+
*/
|
|
45
|
+
export function dispatchBack(): boolean {
|
|
46
|
+
for (let index = handlers.length - 1; index >= 0; index -= 1) {
|
|
47
|
+
try {
|
|
48
|
+
if (handlers[index]()) return true;
|
|
49
|
+
} catch {
|
|
50
|
+
// Fall through to the next handler; history is the final fallback.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Install `window.__kabmoBack`. Idempotent — a hot reload re-runs it. */
|
|
57
|
+
export function installBackDispatcher(): void {
|
|
58
|
+
if (typeof window === 'undefined') return;
|
|
59
|
+
(window as unknown as Record<string, unknown>)[BACK_PROPERTY] = dispatchBack;
|
|
60
|
+
}
|
package/src/client/main.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { installAuthFetchBridge, primeAuthState } from '@/client/hooks/ui/auth';
|
|
|
8
8
|
import { installPluginRuntime } from '@/client/lib/plugins/runtime';
|
|
9
9
|
import { installUiKit } from '@/client/lib/plugins/kit';
|
|
10
10
|
import { installScrollbarActivity } from '@/client/lib/scrollbar-activity';
|
|
11
|
+
import { installBackDispatcher } from '@/client/lib/native/back';
|
|
11
12
|
import { installFileLaunchHandler } from '@/client/lib/launch/file-handlers';
|
|
12
13
|
|
|
13
14
|
import '@/client/tailwind.css';
|
|
@@ -53,6 +54,11 @@ installUiKit();
|
|
|
53
54
|
// already covered.
|
|
54
55
|
installScrollbarActivity();
|
|
55
56
|
|
|
57
|
+
// The native shell asks `window.__kabmoBack()` before walking WebView history.
|
|
58
|
+
// Installed before the first render for the same reason the listener above is:
|
|
59
|
+
// a back press during hydration must find the dispatcher, not `undefined`.
|
|
60
|
+
installBackDispatcher();
|
|
61
|
+
|
|
56
62
|
// A file the OS handed to Kabmo (PWA File Handling) arrives as a
|
|
57
63
|
// `FileSystemFileHandle` on `launchQueue`, and the consumer must be installed
|
|
58
64
|
// before the first render: launches that happened before the script ran are
|
package/src/client/tailwind.css
CHANGED
|
@@ -43,6 +43,12 @@
|
|
|
43
43
|
of the K's upper arm (`public/icon.svg`) — and it is the only chroma
|
|
44
44
|
outside the error red. */
|
|
45
45
|
--color-brand: #f26522;
|
|
46
|
+
/* The icon's tile and the ink on it, also not theme tokens: the wordmark's K
|
|
47
|
+
is the app icon (`public/icon.svg`), so its green plate and cream strokes
|
|
48
|
+
must be identical in every palette — including the dark ones, where a
|
|
49
|
+
themed surface would turn the plate into a hole. */
|
|
50
|
+
--color-brand-tile: #0b5d3b;
|
|
51
|
+
--color-brand-cream: #f7f4ea;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
@layer base {
|
|
@@ -102,8 +102,25 @@ export const GIT_BRANCH_ACTIONS: Record<string, GitActionHandler> = {
|
|
|
102
102
|
},
|
|
103
103
|
|
|
104
104
|
async sync({ targetDir }) {
|
|
105
|
+
// A merge/rebase already stopped on a conflict is a state the operation
|
|
106
|
+
// bar owns: a pull there is refused by git ("You have not concluded your
|
|
107
|
+
// merge"), and letting that refusal through would report the conflict as
|
|
108
|
+
// a failed sync. Naming it points the user at the bar instead.
|
|
109
|
+
const before = await readGitState(targetDir);
|
|
110
|
+
if (before.operation) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Finish the ${OPERATION_LABELS[before.operation]} first — Sync is unavailable while an operation is in progress.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
105
115
|
await pullBranch(targetDir);
|
|
106
|
-
|
|
116
|
+
// A pull that stopped on a conflict has nothing to push yet: the merge is
|
|
117
|
+
// unresolved, so `git push` refuses ("Updates were rejected because the
|
|
118
|
+
// remote contains work…") and the route would report a failed Sync over a
|
|
119
|
+
// conflict the panel is about to offer to resolve. The operation bar is
|
|
120
|
+
// the surface that has to be reached, so the push is skipped and the
|
|
121
|
+
// working tree is left exactly as the pull left it.
|
|
122
|
+
const after = await readGitState(targetDir);
|
|
123
|
+
if (!after.operation) await pushBranch(targetDir);
|
|
107
124
|
// Both commands already moved the tracking ref; the next read must not
|
|
108
125
|
// fetch again to see a state it just produced.
|
|
109
126
|
markRemoteRefsFresh(targetDir);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `sync` ACTION, not just the pull under it.
|
|
8
|
+
*
|
|
9
|
+
* `pullBranch` is pinned by `git-publish.test.ts`; what is pinned here is the
|
|
10
|
+
* handler's own two rules, both invisible from the pull alone:
|
|
11
|
+
*
|
|
12
|
+
* - A pull that stops on a conflict SKIPS the push. `git push` refuses while
|
|
13
|
+
* the merge is unresolved, so without the skip the route answered 400 over a
|
|
14
|
+
* conflict the operation bar was about to offer to resolve — the reported
|
|
15
|
+
* "Sync failed" on the exact state the panel has a surface for.
|
|
16
|
+
* - A tree already in an operation is refused BY NAME rather than by git's
|
|
17
|
+
* "You have not concluded your merge" — the panel can only act on an
|
|
18
|
+
* operation it knows about, and an error that names it is what points the
|
|
19
|
+
* user at the bar.
|
|
20
|
+
*
|
|
21
|
+
* Driven against real repositories: the exit codes, the merge commit and the
|
|
22
|
+
* `MERGE_HEAD` file are the whole subject.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { GIT_BRANCH_ACTIONS } from '@/server/lib/fs/git-actions-branch';
|
|
30
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
31
|
+
|
|
32
|
+
const tempDirs: string[] = [];
|
|
33
|
+
|
|
34
|
+
function tempDir(): string {
|
|
35
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-gitsync-action-'));
|
|
36
|
+
tempDirs.push(dir);
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function git(cwd: string, ...args: string[]): string {
|
|
41
|
+
const result = Bun.spawnSync({ cmd: ['git', ...args], cwd });
|
|
42
|
+
if (result.exitCode !== 0) {
|
|
43
|
+
throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString()}`);
|
|
44
|
+
}
|
|
45
|
+
return result.stdout.toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function initRepo(cwd: string): void {
|
|
49
|
+
git(cwd, 'init', '-q', '-b', 'main');
|
|
50
|
+
git(cwd, 'config', 'user.email', 'test@example.invalid');
|
|
51
|
+
git(cwd, 'config', 'user.name', 'Test');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function commitFile(cwd: string, name: string, message: string, content?: string): void {
|
|
55
|
+
fs.writeFileSync(path.join(cwd, name), content ?? `${name}\n`);
|
|
56
|
+
git(cwd, 'add', '-A');
|
|
57
|
+
git(cwd, 'commit', '-q', '-m', message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A bare origin plus a clone tracking it, each on its own temp path. */
|
|
61
|
+
function makeClone(): { origin: string; clone: string } {
|
|
62
|
+
const origin = tempDir();
|
|
63
|
+
git(origin, 'init', '-q', '--bare', '-b', 'main');
|
|
64
|
+
const clone = tempDir();
|
|
65
|
+
git(tempDir(), 'clone', '-q', origin, clone);
|
|
66
|
+
initRepo(clone);
|
|
67
|
+
commitFile(clone, 'seed.txt', 'seed');
|
|
68
|
+
git(clone, 'push', '-q', '-u', 'origin', 'main');
|
|
69
|
+
return { origin, clone };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Commit `name` in a second clone of `origin` and push it. */
|
|
73
|
+
function pushFromOtherClone(origin: string, name: string, message: string, content?: string): void {
|
|
74
|
+
const other = tempDir();
|
|
75
|
+
git(tempDir(), 'clone', '-q', origin, other);
|
|
76
|
+
initRepo(other);
|
|
77
|
+
commitFile(other, name, message, content);
|
|
78
|
+
git(other, 'push', '-q');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function counts(cwd: string): string {
|
|
82
|
+
return git(cwd, 'rev-list', '--count', '--left-right', 'HEAD...@{upstream}').trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One sync, through the same handler the route dispatches. */
|
|
86
|
+
async function sync(cwd: string): Promise<void> {
|
|
87
|
+
const handler = GIT_BRANCH_ACTIONS.sync;
|
|
88
|
+
await handler({ formData: new FormData(), targetDir: cwd });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
afterAll(() => {
|
|
92
|
+
for (const dir of tempDirs) removeDir(dir);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('the sync action', () => {
|
|
96
|
+
test('reconciles a diverged branch and publishes the result', async () => {
|
|
97
|
+
const { origin, clone } = makeClone();
|
|
98
|
+
pushFromOtherClone(origin, 'other.txt', 'other');
|
|
99
|
+
commitFile(clone, 'mine.txt', 'mine');
|
|
100
|
+
git(clone, 'fetch', '-q');
|
|
101
|
+
expect(counts(clone)).toBe('1\t1');
|
|
102
|
+
|
|
103
|
+
await sync(clone);
|
|
104
|
+
|
|
105
|
+
// Both sides landed and the remote has them: nothing ahead or behind.
|
|
106
|
+
expect(counts(clone)).toBe('0\t0');
|
|
107
|
+
expect(fs.existsSync(path.join(clone, 'other.txt'))).toBe(true);
|
|
108
|
+
expect(git(clone, 'log', '-1', '--pretty=%s')).toContain('Merge branch');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('leaves a conflicted merge open and does not push over it', async () => {
|
|
112
|
+
const { origin, clone } = makeClone();
|
|
113
|
+
commitFile(clone, 'seed.txt', 'mine', 'mine\n');
|
|
114
|
+
pushFromOtherClone(origin, 'seed.txt', 'theirs', 'theirs\n');
|
|
115
|
+
git(clone, 'fetch', '-q');
|
|
116
|
+
|
|
117
|
+
// The pull exits 1 with MERGE_HEAD in place; the handler must treat that as
|
|
118
|
+
// the state to show, not as a failure.
|
|
119
|
+
await sync(clone);
|
|
120
|
+
|
|
121
|
+
expect(fs.existsSync(path.join(clone, '.git', 'MERGE_HEAD'))).toBe(true);
|
|
122
|
+
expect(git(clone, 'status', '--porcelain=v1')).toContain('UU seed.txt');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('refuses by name while an operation is already in progress', async () => {
|
|
126
|
+
const { origin, clone } = makeClone();
|
|
127
|
+
commitFile(clone, 'seed.txt', 'mine', 'mine\n');
|
|
128
|
+
pushFromOtherClone(origin, 'seed.txt', 'theirs', 'theirs\n');
|
|
129
|
+
git(clone, 'fetch', '-q');
|
|
130
|
+
// Start the merge the way the panel does, then try to sync over it.
|
|
131
|
+
Bun.spawnSync({ cmd: ['git', 'merge', '--no-edit', '--', '@{upstream}'], cwd: clone });
|
|
132
|
+
expect(fs.existsSync(path.join(clone, '.git', 'MERGE_HEAD'))).toBe(true);
|
|
133
|
+
|
|
134
|
+
// `expect(...).rejects` is typed as returning void by Bun's matcher types,
|
|
135
|
+
// so awaiting it is a no-op — the explicit catch is what actually waits.
|
|
136
|
+
let refusal = '';
|
|
137
|
+
try {
|
|
138
|
+
await sync(clone);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
refusal = error instanceof Error ? error.message : String(error);
|
|
141
|
+
}
|
|
142
|
+
expect(refusal).toMatch(/finish the merge first/i);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* refusal, not a failure — and treating it as an error aborted Sync before it
|
|
14
14
|
* ever reached the push. Driven against real repositories, because the exit
|
|
15
15
|
* codes and the `-u` upstream write are what the module exists for.
|
|
16
|
+
*
|
|
17
|
+
* The pull suite also pins the DIVERGED case, where `--ff-only` exits 128 and
|
|
18
|
+
* Sync silently did nothing (one local commit, one on origin) — see the merge
|
|
19
|
+
* test and `pullBranch`'s own comment.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
@@ -66,6 +70,32 @@ function upstreamOf(cwd: string): string {
|
|
|
66
70
|
return git(cwd, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').trim();
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/** "<ahead>\t<behind>" against the upstream — `1\t1` is a diverged branch. */
|
|
74
|
+
function counts(cwd: string): string {
|
|
75
|
+
return git(cwd, 'rev-list', '--count', '--left-right', 'HEAD...@{upstream}').trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A second clone of `origin` that commits `name` and pushes it, so the branch
|
|
80
|
+
* under test is behind by one — the other half of a divergence.
|
|
81
|
+
*
|
|
82
|
+
* `content` overrides what the file holds, which is what makes the two sides
|
|
83
|
+
* CONFLICT instead of one of them being an unrelated new path.
|
|
84
|
+
*/
|
|
85
|
+
function pushFromOtherClone(origin: string, name: string, message: string, content?: string): void {
|
|
86
|
+
const other = tempDir();
|
|
87
|
+
git(tempDir(), 'clone', '-q', origin, other);
|
|
88
|
+
initRepo(other);
|
|
89
|
+
if (content === undefined) {
|
|
90
|
+
commitFile(other, name, message);
|
|
91
|
+
} else {
|
|
92
|
+
fs.writeFileSync(path.join(other, name), content);
|
|
93
|
+
git(other, 'add', '-A');
|
|
94
|
+
git(other, 'commit', '-q', '-m', message);
|
|
95
|
+
}
|
|
96
|
+
git(other, 'push', '-q');
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
afterAll(() => {
|
|
70
100
|
for (const dir of tempDirs) removeDir(dir);
|
|
71
101
|
});
|
|
@@ -144,4 +174,70 @@ describe('pullBranch', () => {
|
|
|
144
174
|
|
|
145
175
|
expect(fs.existsSync(path.join(clone, 'remote.txt'))).toBe(true);
|
|
146
176
|
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The reported Sync failure: one local commit and one on origin.
|
|
180
|
+
*
|
|
181
|
+
* `--ff-only` exits 128 here ("Not possible to fast-forward, aborting."), so
|
|
182
|
+
* Sync threw before the push and the badge stayed at ↑1 ↓1 with git's advice
|
|
183
|
+
* text on screen. The fix is the merge pull, and the assertion is the state
|
|
184
|
+
* after a REAL push: nothing left ahead or behind.
|
|
185
|
+
*/
|
|
186
|
+
test('merges a diverged branch instead of refusing, and the push then lands', async () => {
|
|
187
|
+
const { origin, clone } = makeClone();
|
|
188
|
+
pushFromOtherClone(origin, 'other.txt', 'other');
|
|
189
|
+
commitFile(clone, 'mine.txt', 'mine');
|
|
190
|
+
git(clone, 'fetch', '-q');
|
|
191
|
+
expect(counts(clone)).toBe('1\t1');
|
|
192
|
+
|
|
193
|
+
await pullBranch(clone);
|
|
194
|
+
await pushBranch(clone);
|
|
195
|
+
|
|
196
|
+
expect(counts(clone)).toBe('0\t0');
|
|
197
|
+
// The merge commit carries the remote branch's name, which is what a pull
|
|
198
|
+
// writes — not a rebase, whose history would hold two linear commits.
|
|
199
|
+
expect(git(clone, 'log', '-1', '--pretty=%s')).toContain('Merge branch');
|
|
200
|
+
expect(fs.existsSync(path.join(clone, 'other.txt'))).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A pull that stops on a conflict exits 1 with `MERGE_HEAD` in place — the
|
|
205
|
+
* state the operation bar exists to show, not a failed sync. Throwing there
|
|
206
|
+
* reported the conflict as an error and never reached the panel's own
|
|
207
|
+
* "resolve the conflicts" surface.
|
|
208
|
+
*/
|
|
209
|
+
test('accepts a pull that stops on a conflict, leaving the merge open', async () => {
|
|
210
|
+
const { origin, clone } = makeClone();
|
|
211
|
+
// `seed.txt` exists on both sides already, so the two edits CONFLICT —
|
|
212
|
+
// a file only one side has would merge cleanly (`AA` only when both
|
|
213
|
+
// created it, which is a different shape than a content conflict).
|
|
214
|
+
fs.writeFileSync(path.join(clone, 'seed.txt'), 'mine\n');
|
|
215
|
+
git(clone, 'commit', '-q', '-am', 'mine');
|
|
216
|
+
pushFromOtherClone(origin, 'seed.txt', 'theirs', 'theirs\n');
|
|
217
|
+
git(clone, 'fetch', '-q');
|
|
218
|
+
|
|
219
|
+
await pullBranch(clone);
|
|
220
|
+
|
|
221
|
+
expect(fs.existsSync(path.join(clone, '.git', 'MERGE_HEAD'))).toBe(true);
|
|
222
|
+
expect(git(clone, 'status', '--porcelain=v1')).toContain('UU seed.txt');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The pull is pinned to merge semantics whatever the repository's own
|
|
227
|
+
* `pull.ff`/`pull.rebase` say — a Sync button must not mean two things.
|
|
228
|
+
*/
|
|
229
|
+
test('does not inherit pull.ff=only, which reproduces the refusal', async () => {
|
|
230
|
+
const { origin, clone } = makeClone();
|
|
231
|
+
pushFromOtherClone(origin, 'other.txt', 'other');
|
|
232
|
+
commitFile(clone, 'mine.txt', 'mine');
|
|
233
|
+
git(clone, 'fetch', '-q');
|
|
234
|
+
git(clone, 'config', 'pull.ff', 'only');
|
|
235
|
+
|
|
236
|
+
await pullBranch(clone);
|
|
237
|
+
|
|
238
|
+
// Ahead by the local commit plus the merge commit, and no longer behind:
|
|
239
|
+
// the pull brought origin in rather than refusing it.
|
|
240
|
+
expect(counts(clone)).toBe('2\t0');
|
|
241
|
+
expect(git(clone, 'log', '-1', '--pretty=%s')).toContain('Merge branch');
|
|
242
|
+
});
|
|
147
243
|
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* branch" when the branch was never published, so Sync failed outright on
|
|
14
14
|
* exactly the branch whose commits it was supposed to send. `push -u <remote>
|
|
15
15
|
* HEAD` sets the upstream while it publishes.
|
|
16
|
-
* - `git pull
|
|
16
|
+
* - `git pull` exits 1 with "There is no tracking information for the
|
|
17
17
|
* current branch" for the same branch — a refusal, not a failure of the
|
|
18
18
|
* user's intent: there is nothing to pull yet. Treating it as an error
|
|
19
19
|
* aborted Sync before it reached the push.
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { runGit, type GitRunResult } from '@/server/lib/fs/git-run';
|
|
26
|
+
import { readGitState } from '@/server/lib/fs/git-state';
|
|
26
27
|
|
|
27
28
|
/** Runs a git command and throws with its stderr when it exits non-zero. */
|
|
28
29
|
async function expectOk(args: string[], cwd: string, timeout?: number): Promise<GitRunResult> {
|
|
@@ -62,12 +63,43 @@ export async function pushBranch(cwd: string): Promise<void> {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* Pull
|
|
66
|
+
* Pull the branch's upstream, standing down on a branch with no upstream.
|
|
66
67
|
*
|
|
67
68
|
* Nothing to pull is not an error, and aborting here is what stopped Sync from
|
|
68
69
|
* ever publishing a fresh branch.
|
|
70
|
+
*
|
|
71
|
+
* **`--ff-only` was wrong, and it is the reported "Sync does nothing when I
|
|
72
|
+
* have one local commit and one on origin".** On a DIVERGED branch git refuses
|
|
73
|
+
* it outright — measured: `fatal: Not possible to fast-forward, aborting.`
|
|
74
|
+
* (exit 128) — so Sync threw before it reached the push, the badge stayed at
|
|
75
|
+
* `↑1 ↓1`, and the panel reported git's advice text as an error. The two
|
|
76
|
+
* branches cannot be reconciled by moving a pointer; they need a merge commit,
|
|
77
|
+
* which is what `git pull` does by default. `--ff --no-rebase` states that
|
|
78
|
+
* explicitly instead of inheriting the user's `pull.rebase`/`pull.ff`: both
|
|
79
|
+
* were measured to change the answer (`pull.ff=only` reproduces the refusal,
|
|
80
|
+
* `pull.rebase=true` rewrites local commits), and a Sync button must not mean
|
|
81
|
+
* two different things per repository. A fast-forward still happens where one
|
|
82
|
+
* is possible — verified against a behind-only branch.
|
|
83
|
+
*
|
|
84
|
+
* **A merge that stops on a conflict is SUCCESS, not a failure.** Measured:
|
|
85
|
+
* the command exits 1 with `MERGE_HEAD` in place and `UU` rows in the status —
|
|
86
|
+
* the state the panel's operation bar exists to show. Throwing there reported
|
|
87
|
+
* the conflict as a failed sync and hid the bar's own instructions, so the
|
|
88
|
+
* caller checks for a started merge exactly as `expectStarted` does for a
|
|
89
|
+
* merge the user asked for.
|
|
69
90
|
*/
|
|
70
91
|
export async function pullBranch(cwd: string): Promise<void> {
|
|
71
92
|
if (!(await readUpstream(cwd))) return;
|
|
72
|
-
await
|
|
93
|
+
const result = await runGit(['pull', '--ff', '--no-rebase', '--no-edit'], {
|
|
94
|
+
cwd,
|
|
95
|
+
timeout: 120_000,
|
|
96
|
+
maxBuffer: 1024 * 1024,
|
|
97
|
+
});
|
|
98
|
+
if (result.error) throw result.error;
|
|
99
|
+
if (result.exitCode === 0) return;
|
|
100
|
+
const state = await readGitState(cwd);
|
|
101
|
+
if (state.operation === 'merge') return;
|
|
102
|
+
throw new Error(
|
|
103
|
+
result.stderr.trim() || result.stdout.trim() || `git exited with code ${result.exitCode}`,
|
|
104
|
+
);
|
|
73
105
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the Files panel lists.
|
|
8
|
+
*
|
|
9
|
+
* The panel used to drop a hardcoded set of names (`node_modules`, `.git`,
|
|
10
|
+
* `dist`, `build`) at every depth, which hid `packages/android/dist` — the
|
|
11
|
+
* built APKs — from the user with no way to reach it. There is now no name
|
|
12
|
+
* filter at all: every entry is listed, and the `ignored` flag (one
|
|
13
|
+
* `git check-ignore` per listing) is what marks the untracked ones for dimming.
|
|
14
|
+
*
|
|
15
|
+
* Driven against a real temp directory, because the listing stats every entry
|
|
16
|
+
* and asks git about it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { listDirectoryEntries } from '@/server/lib/fs/listing';
|
|
24
|
+
|
|
25
|
+
const roots: string[] = [];
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
while (roots.length > 0) fs.rmSync(roots.pop() as string, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeRoot(): string {
|
|
32
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-listing-'));
|
|
33
|
+
roots.push(dir);
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mkdirs(root: string, names: string[]): void {
|
|
38
|
+
for (const name of names) fs.mkdirSync(path.join(root, name), { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('listDirectoryEntries', () => {
|
|
42
|
+
test('lists every entry, node_modules and .git included', async () => {
|
|
43
|
+
const root = makeRoot();
|
|
44
|
+
mkdirs(root, ['src', 'dist', 'build', 'node_modules/preact', '.git/objects']);
|
|
45
|
+
|
|
46
|
+
const names = (await listDirectoryEntries(root, root)).map((entry) => entry.name);
|
|
47
|
+
|
|
48
|
+
expect(names).toContain('dist');
|
|
49
|
+
expect(names).toContain('build');
|
|
50
|
+
expect(names).toContain('src');
|
|
51
|
+
// No name table: the panel must be able to reach anything on disk.
|
|
52
|
+
expect(names).toContain('node_modules');
|
|
53
|
+
expect(names).toContain('.git');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a nested dist is listed where it lives, not filtered by name', async () => {
|
|
57
|
+
const root = makeRoot();
|
|
58
|
+
mkdirs(root, ['packages/android/dist', 'packages/core']);
|
|
59
|
+
|
|
60
|
+
const packages = await listDirectoryEntries(path.join(root, 'packages'), root);
|
|
61
|
+
expect(packages.map((entry) => entry.name)).toEqual(['android', 'core']);
|
|
62
|
+
|
|
63
|
+
const android = await listDirectoryEntries(path.join(root, 'packages/android'), root);
|
|
64
|
+
expect(android.map((entry) => entry.name)).toEqual(['dist']);
|
|
65
|
+
// Paths stay relative to the LISTED root, which is what the tree keys on,
|
|
66
|
+
// and they are POSIX-spelled whatever the platform: `path.relative` answers
|
|
67
|
+
// `packages\android\dist` on Windows, which keyed every row differently from
|
|
68
|
+
// the same row on POSIX and — because `git check-ignore` rejects a
|
|
69
|
+
// backslash — left the whole listing un-dimmed there. This assertion is the
|
|
70
|
+
// guard; it is what failed on the Windows CI leg.
|
|
71
|
+
expect(android[0]!.path).toBe('packages/android/dist');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('folders sort before files, and dot-entries are listed', async () => {
|
|
75
|
+
const root = makeRoot();
|
|
76
|
+
fs.mkdirSync(path.join(root, 'zebra'));
|
|
77
|
+
fs.writeFileSync(path.join(root, 'alpha.txt'), '');
|
|
78
|
+
fs.writeFileSync(path.join(root, '.env'), 'K=1\n');
|
|
79
|
+
|
|
80
|
+
const entries = await listDirectoryEntries(root, root);
|
|
81
|
+
const names = entries.map((entry) => entry.name);
|
|
82
|
+
|
|
83
|
+
expect(entries[0]!.type).toBe('folder');
|
|
84
|
+
expect(names).toEqual(['zebra', '.env', 'alpha.txt']);
|
|
85
|
+
});
|
|
86
|
+
});
|