@kabmo-labs/kabmo 0.1.0 → 0.3.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 (80) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +285 -285
  3. package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/package.json +1 -1
  6. package/src/client/bootstrap.d.ts +17 -0
  7. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  8. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  9. package/src/client/components/common/header-controls/index.tsx +53 -4
  10. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  11. package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
  12. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  13. package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
  14. package/src/client/components/layout/session-sidebar/index.tsx +8 -0
  15. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  16. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  17. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  18. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
  20. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
  21. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  22. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  23. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  24. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  25. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  26. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  27. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  28. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  29. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  30. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  31. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  32. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  33. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  34. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  36. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  37. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  38. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  39. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  40. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  41. package/src/client/data/settings/omp-schema.json +89 -4
  42. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  43. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  44. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  45. package/src/client/hooks/chat/timeline/index.ts +2 -1
  46. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  47. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  48. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  49. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  50. package/src/client/hooks/ui/media-query.ts +41 -0
  51. package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
  52. package/src/client/hooks/ui/updates/android-download.ts +143 -0
  53. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  54. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  55. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  56. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  57. package/src/client/lib/native/back.test.ts +108 -0
  58. package/src/client/lib/native/back.ts +60 -0
  59. package/src/client/main.tsx +6 -0
  60. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  61. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  62. package/src/server/lib/fs/git-publish.test.ts +96 -0
  63. package/src/server/lib/fs/git-publish.ts +35 -3
  64. package/src/server/lib/fs/listing.test.ts +86 -0
  65. package/src/server/lib/fs/listing.ts +20 -6
  66. package/src/server/lib/updates/android-release.test.ts +138 -0
  67. package/src/server/lib/updates/android-release.ts +183 -0
  68. package/src/server/plugins/ssr.ts +14 -2
  69. package/src/server/routes/fs/read.ts +24 -9
  70. package/src/server/routes/sessions/batch-delete.test.ts +14 -11
  71. package/src/server/routes/updates/android.ts +22 -0
  72. package/src/server/routes/updates/index.ts +2 -0
  73. package/src/shared/lib/updates/shell-ua.test.ts +121 -0
  74. package/src/shared/lib/updates/shell-ua.ts +56 -0
  75. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  76. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  77. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  78. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  79. package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
  80. package/dist/client/static/js/index-yn17t04b.css +0 -1
@@ -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
+ });
@@ -17,9 +17,6 @@ import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { collectIgnoredPaths } from '@/server/lib/fs/git-ignore';
19
19
 
20
- /** Directory names never listed: build output and VCS metadata. */
21
- const NOISE_DIRS: Record<string, true> = { node_modules: true, '.git': true, dist: true, build: true };
22
-
23
20
  export interface DirectoryEntry {
24
21
  id: string;
25
22
  name: string;
@@ -30,13 +27,30 @@ export interface DirectoryEntry {
30
27
  ignored: boolean;
31
28
  }
32
29
 
33
- /** Read one directory, folders first then files, each marked ignored or not. */
30
+ /**
31
+ * Read one directory, folders first then files, each marked ignored or not.
32
+ *
33
+ * EVERY entry is listed — `node_modules`, `.git`, `dist`, `build` and dot-files
34
+ * included. There is no name table: filtering by name cannot tell a dependency
35
+ * tree from a folder of release artifacts (`packages/android/dist` holds the
36
+ * built APKs), and it hid directories the user could not reach any other way.
37
+ * The panel's own signal for "this is untracked noise" is the `ignored` flag,
38
+ * which dims the row via `git check-ignore` instead of deleting it — and a tree
39
+ * that is expensive to expand costs nothing until someone expands it, because
40
+ * children are lazy.
41
+ */
34
42
  export async function listDirectoryEntries(dirPath: string, rootPath: string): Promise<DirectoryEntry[]> {
35
43
  const listed = (await fs.promises.readdir(dirPath))
36
- .filter((child) => !NOISE_DIRS[child])
37
44
  .map((child) => {
38
45
  const full = path.join(dirPath, child);
39
- return { child, full, rel: path.relative(rootPath, full) };
46
+ // POSIX spelling, because `rel` is not only a display value: it is the
47
+ // tree's id/key, the `fs:` topic's path, and the path handed to
48
+ // `git check-ignore` — which rejects a backslash outright, so on Windows
49
+ // every row came back un-dimmed and keyed `packages\android\dist`. The
50
+ // rest of the server states the same convention (`lib/fs/root.ts`,
51
+ // `lib/fs/transfer.ts`).
52
+ const rel = path.relative(rootPath, full).split(path.sep).join('/');
53
+ return { child, full, rel };
40
54
  });
41
55
 
42
56
  const ignored = await collectIgnoredPaths(rootPath, listed.map((entry) => entry.rel));
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { beforeEach, describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ fetchAndroidRelease,
10
+ resetAndroidReleaseCache,
11
+ versionFromAssetName,
12
+ } from '@/server/lib/updates/android-release';
13
+
14
+ /**
15
+ * The asset name is the only place the APK's version exists: the release TAG
16
+ * names the SERVER version, and the two move independently. A parser that read
17
+ * the tag would compare a server version against an APK version.
18
+ */
19
+ describe('versionFromAssetName', () => {
20
+ test('reads the version the release workflow writes', () => {
21
+ expect(versionFromAssetName('kabmo-android-v1.2.3.apk')).toBe('1.2.3');
22
+ });
23
+
24
+ test('accepts a hand-uploaded name without the v', () => {
25
+ expect(versionFromAssetName('kabmo-android-1.2.3.apk')).toBe('1.2.3');
26
+ });
27
+
28
+ test('ignores anything that is not the APK', () => {
29
+ expect(versionFromAssetName('kabmo-android-v1.2.3.apk.zip')).toBeNull();
30
+ expect(versionFromAssetName('source.tar.gz')).toBeNull();
31
+ expect(versionFromAssetName('kabmo-android-latest.apk')).toBeNull();
32
+ expect(versionFromAssetName('')).toBeNull();
33
+ });
34
+ });
35
+
36
+ /** A fetch double that answers one body, and counts its calls. */
37
+ function stubFetch(body: unknown, ok = true) {
38
+ const calls: string[] = [];
39
+ const impl = (async (input: string | URL | Request) => {
40
+ calls.push(String(input));
41
+ return {
42
+ ok,
43
+ json: async () => body,
44
+ } as Response;
45
+ }) as unknown as typeof fetch;
46
+ return { impl, calls };
47
+ }
48
+
49
+ describe('fetchAndroidRelease', () => {
50
+ // The cache is process-wide (see `resetAndroidReleaseCache`), so every case
51
+ // starts from a cold one or it reads the previous case's answer.
52
+ beforeEach(() => resetAndroidReleaseCache());
53
+
54
+ test('picks the APK asset and its version', async () => {
55
+ const { impl } = stubFetch([
56
+ {
57
+ html_url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
58
+ assets: [
59
+ { name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
60
+ {
61
+ name: 'kabmo-android-v1.2.3.apk',
62
+ browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
63
+ },
64
+ ],
65
+ },
66
+ ]);
67
+
68
+ const release = await fetchAndroidRelease(impl);
69
+ expect(release).toEqual({
70
+ url: 'https://x/kabmo-android-v1.2.3.apk',
71
+ latest: '1.2.3',
72
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
73
+ });
74
+ });
75
+
76
+ test('skips a newer release that carries no APK', async () => {
77
+ // This is the shape that broke the button: `release.yml` releases the npm
78
+ // packages FIRST, so `kabmo-sdk/v0.0.2` is newer than the app release that
79
+ // holds the APK. Asking `/releases/latest` answered the package release and
80
+ // the button vanished while the APK sat one entry below.
81
+ const { impl } = stubFetch([
82
+ { html_url: 'https://x/kabmo-sdk', assets: [{ name: 'source.tar.gz' }] },
83
+ { html_url: 'https://x/kabmo-ui', assets: [] },
84
+ {
85
+ html_url: 'https://x/app',
86
+ assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
87
+ },
88
+ ]);
89
+
90
+ expect(await fetchAndroidRelease(impl)).toEqual({
91
+ url: 'https://x/a.apk',
92
+ latest: '0.3.0',
93
+ releaseUrl: 'https://x/app',
94
+ });
95
+ });
96
+
97
+ test('skips drafts and prereleases', async () => {
98
+ const { impl } = stubFetch([
99
+ {
100
+ draft: true,
101
+ html_url: 'https://x/draft',
102
+ assets: [{ name: 'kabmo-android-v9.9.9.apk', browser_download_url: 'https://x/draft.apk' }],
103
+ },
104
+ {
105
+ prerelease: true,
106
+ html_url: 'https://x/pre',
107
+ assets: [{ name: 'kabmo-android-v8.0.0.apk', browser_download_url: 'https://x/pre.apk' }],
108
+ },
109
+ {
110
+ html_url: 'https://x/app',
111
+ assets: [{ name: 'kabmo-android-v0.3.0.apk', browser_download_url: 'https://x/a.apk' }],
112
+ },
113
+ ]);
114
+
115
+ expect((await fetchAndroidRelease(impl))?.latest).toBe('0.3.0');
116
+ });
117
+
118
+ test('answers null for a list with no APK anywhere', async () => {
119
+ const { impl } = stubFetch([
120
+ { html_url: 'https://x', assets: [{ name: 'notes.md' }] },
121
+ { html_url: 'https://y', assets: [] },
122
+ ]);
123
+ expect(await fetchAndroidRelease(impl)).toEqual({ url: null, latest: null, releaseUrl: null });
124
+ });
125
+
126
+ test('answers null when the probe fails', async () => {
127
+ // An offline box or a rate-limited GitHub must not take the sidebar down,
128
+ // and a failure must not be read as "no newer version" either — it is
129
+ // simply no offer.
130
+ const { impl } = stubFetch({}, false);
131
+ expect(await fetchAndroidRelease(impl)).toBeNull();
132
+ });
133
+
134
+ test('answers null when the body is malformed', async () => {
135
+ const { impl } = stubFetch({ assets: 'nope' });
136
+ expect(await fetchAndroidRelease(impl)).toBeNull();
137
+ });
138
+ });