@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.
Files changed (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. 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
+ }
@@ -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
@@ -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
- await pushBranch(targetDir);
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 --ff-only` exits 1 with "There is no tracking information for the
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 with fast-forward only, standing down on a branch with no upstream.
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 expectOk(['pull', '--ff-only'], cwd, 120000);
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
+ });