@kabmo-labs/kabmo 0.0.1 → 0.1.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 +323 -323
- package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
- package/dist/client/static/js/index-yn17t04b.css +1 -0
- 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/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/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/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 +28 -1
- package/src/client/components/layout/session-sidebar/index.tsx +87 -0
- package/src/client/components/mobile/SessionSidebar.tsx +78 -0
- 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 +2 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
- 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/tailwind.css +6 -0
- 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/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/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/sessions/batch-delete.test.ts +139 -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/apply.ts +1 -1
- 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/sidebar-toolbar.ts +6 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import { useEffect, useState } from 'preact/hooks';
|
|
22
22
|
import type { FunctionComponent } from 'preact/compat';
|
|
23
|
-
import {
|
|
23
|
+
import { Eye, EyeOff, Loader2 } from 'lucide-preact';
|
|
24
24
|
import { login, probeSession } from '@/client/hooks/ui/auth';
|
|
25
25
|
import { BrandWordmark } from '@/client/components/common/brand-wordmark';
|
|
26
26
|
|
|
27
27
|
export const LoginScreen: FunctionComponent = () => {
|
|
28
28
|
const [password, setPassword] = useState('');
|
|
29
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
29
30
|
const [trustDevice, setTrustDevice] = useState(false);
|
|
30
31
|
const [error, setError] = useState('');
|
|
31
32
|
const [busy, setBusy] = useState(false);
|
|
@@ -76,31 +77,51 @@ export const LoginScreen: FunctionComponent = () => {
|
|
|
76
77
|
return (
|
|
77
78
|
<div className="min-h-dvh flex items-center justify-center bg-canvas text-ink px-4">
|
|
78
79
|
<form onSubmit={submit} className="w-full max-w-xs space-y-4">
|
|
79
|
-
<div className="space-y-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
<div className="space-y-2 text-center">
|
|
81
|
+
{/* The product's own lockup is the header here: one wordmark, drawn
|
|
82
|
+
at hero size, so the login screen and the app cannot disagree
|
|
83
|
+
about what the product is called. No second icon — the K *is* the
|
|
84
|
+
icon, and a key glyph above it said nothing the field's own label
|
|
85
|
+
does not. The eye in the field is a control, not a second mark. */}
|
|
86
|
+
<h1 className="flex items-center justify-center">
|
|
87
|
+
<BrandWordmark className="text-3xl" />
|
|
85
88
|
</h1>
|
|
86
89
|
<p className="text-xs text-ink/60">This instance is password protected.</p>
|
|
87
90
|
</div>
|
|
88
91
|
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
92
|
+
<div className="relative">
|
|
93
|
+
<input
|
|
94
|
+
type={showPassword ? 'text' : 'password'}
|
|
95
|
+
autoComplete="current-password"
|
|
96
|
+
autoFocus
|
|
97
|
+
value={password}
|
|
98
|
+
onInput={(event) => {
|
|
99
|
+
setPassword(event.currentTarget.value);
|
|
100
|
+
if (error) setError('');
|
|
101
|
+
}}
|
|
102
|
+
placeholder="Password"
|
|
103
|
+
aria-label="Password"
|
|
104
|
+
aria-invalid={error ? true : undefined}
|
|
105
|
+
disabled={busy}
|
|
106
|
+
className="w-full bg-paper border border-ink/20 rounded-lg pl-3 pr-10 py-2 text-sm text-ink placeholder-ink/40 focus:outline-none focus:border-ink/50 transition-colors"
|
|
107
|
+
/>
|
|
108
|
+
{/* The eye is a `type` toggle, not a second field: the value lives in
|
|
109
|
+
one state slot either way, so revealing it cannot change what is
|
|
110
|
+
submitted. It is a real button with its own label because the
|
|
111
|
+
alternative — a bare `<svg onClick>` — is unreachable by keyboard
|
|
112
|
+
and unnamed for assistive tech. */}
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => setShowPassword((shown) => !shown)}
|
|
116
|
+
disabled={busy}
|
|
117
|
+
title={showPassword ? 'Hide password' : 'Show password'}
|
|
118
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
119
|
+
aria-pressed={showPassword}
|
|
120
|
+
className="absolute inset-y-0 right-0 flex items-center px-3 text-ink/50 hover:text-ink disabled:opacity-40 transition-colors cursor-pointer disabled:cursor-default"
|
|
121
|
+
>
|
|
122
|
+
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
104
125
|
|
|
105
126
|
<label className="flex items-center gap-2 text-xs text-ink/70">
|
|
106
127
|
<input
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The password field's visibility toggle.
|
|
3
|
+
*
|
|
4
|
+
* The eye is a `type` switch on the SAME input, so what it must not do is move
|
|
5
|
+
* the value: a reveal that rebuilt the field would clear the credential the
|
|
6
|
+
* user just typed. The assertions are about the rendered tree and the input's
|
|
7
|
+
* own `type`/`value`, because a class list proves nothing about either.
|
|
8
|
+
*
|
|
9
|
+
* The header's own assertions live in `index.test.tsx`; this file covers the
|
|
10
|
+
* control beside the field and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { Window } from 'happy-dom';
|
|
15
|
+
import { h, render } from 'preact';
|
|
16
|
+
import { act } from 'preact/test-utils';
|
|
17
|
+
|
|
18
|
+
import { LoginScreen } from '@/client/components/auth/login-screen';
|
|
19
|
+
|
|
20
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
21
|
+
/** The runner's own globals, restored on teardown — deleting them would strip natives later files need. */
|
|
22
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
23
|
+
const nativeFetch = globalThis.fetch;
|
|
24
|
+
|
|
25
|
+
let container: HTMLElement;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
const win = new Window({ url: 'http://localhost' });
|
|
29
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
30
|
+
for (const key of DOM_GLOBALS) {
|
|
31
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
32
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
33
|
+
}
|
|
34
|
+
// The screen re-checks the session on mount; an unanswerable probe leaves the
|
|
35
|
+
// form alone, which is the state under test.
|
|
36
|
+
target.fetch = async () => {
|
|
37
|
+
throw new Error('offline');
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
43
|
+
for (const key of DOM_GLOBALS) {
|
|
44
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
45
|
+
else target[key] = nativeGlobals[key];
|
|
46
|
+
}
|
|
47
|
+
target.fetch = nativeFetch;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (container) render(null, container);
|
|
52
|
+
container?.remove();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function mount(): Promise<HTMLElement> {
|
|
56
|
+
container = document.createElement('div');
|
|
57
|
+
document.body.appendChild(container);
|
|
58
|
+
await act(async () => {
|
|
59
|
+
render(h(LoginScreen, {}), container);
|
|
60
|
+
});
|
|
61
|
+
return container;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The one field the form submits — matched by its label, never by `type`, which is what changes. */
|
|
65
|
+
function passwordField(el: HTMLElement): HTMLInputElement | null {
|
|
66
|
+
return el.querySelector<HTMLInputElement>('input[aria-label="Password"]');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('LoginScreen password visibility', () => {
|
|
70
|
+
test('starts masked, with a labelled toggle beside the field', async () => {
|
|
71
|
+
const el = await mount();
|
|
72
|
+
expect(passwordField(el)?.type).toBe('password');
|
|
73
|
+
const toggle = el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]');
|
|
74
|
+
expect(toggle).toBeTruthy();
|
|
75
|
+
// A real button, not an svg with a click handler: keyboard reachable.
|
|
76
|
+
expect(toggle?.getAttribute('type')).toBe('button');
|
|
77
|
+
expect(toggle?.getAttribute('title')).toBe('Show password');
|
|
78
|
+
expect(toggle?.getAttribute('aria-pressed')).toBe('false');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('reveals the same field and puts it back, keeping the value', async () => {
|
|
82
|
+
const el = await mount();
|
|
83
|
+
await act(async () => {
|
|
84
|
+
const input = passwordField(el);
|
|
85
|
+
if (input) {
|
|
86
|
+
input.value = 's3cret';
|
|
87
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
expect(passwordField(el)?.value).toBe('s3cret');
|
|
91
|
+
|
|
92
|
+
await act(async () => el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]')?.click());
|
|
93
|
+
expect(passwordField(el)?.type).toBe('text');
|
|
94
|
+
expect(passwordField(el)?.value).toBe('s3cret');
|
|
95
|
+
const hide = el.querySelector<HTMLButtonElement>('button[aria-label="Hide password"]');
|
|
96
|
+
expect(hide?.getAttribute('aria-pressed')).toBe('true');
|
|
97
|
+
expect(hide?.getAttribute('title')).toBe('Hide password');
|
|
98
|
+
|
|
99
|
+
await act(async () => hide?.click());
|
|
100
|
+
expect(passwordField(el)?.type).toBe('password');
|
|
101
|
+
expect(passwordField(el)?.value).toBe('s3cret');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the toggle is disabled while a sign-in is in flight', async () => {
|
|
105
|
+
const el = await mount();
|
|
106
|
+
await act(async () => {
|
|
107
|
+
const input = passwordField(el);
|
|
108
|
+
if (input) {
|
|
109
|
+
input.value = 's3cret';
|
|
110
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
// The submit handler awaits `login`, so the in-flight state is observable
|
|
114
|
+
// in the same tick the form is submitted.
|
|
115
|
+
await act(async () => {
|
|
116
|
+
el.querySelector<HTMLFormElement>('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
117
|
+
});
|
|
118
|
+
expect(el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]')?.disabled).toBe(true);
|
|
119
|
+
expect(passwordField(el)?.disabled).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The batch bar's contract, rendered.
|
|
8
|
+
*
|
|
9
|
+
* Three properties are load-bearing and each is a way a batch delete can go
|
|
10
|
+
* wrong: the bar names the mode's noun (a session batch that says "workspace"
|
|
11
|
+
* deletes the wrong thing), Delete is refused with nothing selected, and
|
|
12
|
+
* Select-all is refused once everything already is — a control that looks
|
|
13
|
+
* available but does nothing is how a user concludes the feature is broken.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
17
|
+
import { Window } from 'happy-dom';
|
|
18
|
+
import { h, render } from 'preact';
|
|
19
|
+
import { act } from 'preact/test-utils';
|
|
20
|
+
|
|
21
|
+
import { BatchActionBar } from '@/client/components/common/batch-selection/BatchActionBar';
|
|
22
|
+
import type { BatchActionBarProps } from '@/client/components/common/batch-selection/BatchActionBar';
|
|
23
|
+
|
|
24
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
25
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
26
|
+
|
|
27
|
+
let container: HTMLElement;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
const win = new Window({ url: 'http://localhost' });
|
|
31
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
32
|
+
for (const key of DOM_GLOBALS) {
|
|
33
|
+
nativeGlobals[key] = target[key];
|
|
34
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
40
|
+
for (const key of DOM_GLOBALS) {
|
|
41
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
42
|
+
else target[key] = nativeGlobals[key];
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
container?.remove();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function mount(overrides: Partial<BatchActionBarProps> = {}) {
|
|
51
|
+
const calls = { selectAll: 0, unselectAll: 0, del: 0, cancel: 0 };
|
|
52
|
+
container = document.createElement('div');
|
|
53
|
+
document.body.appendChild(container);
|
|
54
|
+
await act(async () => {
|
|
55
|
+
render(h(BatchActionBar, {
|
|
56
|
+
mode: 'session',
|
|
57
|
+
count: 0,
|
|
58
|
+
total: 3,
|
|
59
|
+
busy: false,
|
|
60
|
+
onSelectAll: () => { calls.selectAll += 1; },
|
|
61
|
+
onUnselectAll: () => { calls.unselectAll += 1; },
|
|
62
|
+
onDelete: () => { calls.del += 1; },
|
|
63
|
+
onCancel: () => { calls.cancel += 1; },
|
|
64
|
+
...overrides,
|
|
65
|
+
}), container);
|
|
66
|
+
});
|
|
67
|
+
return { el: container, calls };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buttonContaining(el: HTMLElement, text: string): HTMLButtonElement | null {
|
|
71
|
+
return Array.from(el.querySelectorAll('button'))
|
|
72
|
+
.find((button) => (button.textContent ?? '').includes(text)) as HTMLButtonElement ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('BatchActionBar', () => {
|
|
76
|
+
test('counts the selection against the scope, in the mode\'s noun', async () => {
|
|
77
|
+
const { el } = await mount({ count: 2, total: 5, scopeLabel: 'kabmo' });
|
|
78
|
+
expect(el.textContent).toContain('2');
|
|
79
|
+
expect(el.textContent).toContain('of 5 sessions selected');
|
|
80
|
+
expect(el.textContent).toContain('in kabmo');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('workspace mode names workspaces and drops the scope clause', async () => {
|
|
84
|
+
const { el } = await mount({ mode: 'workspace', count: 1, total: 4, scopeLabel: 'kabmo' });
|
|
85
|
+
expect(el.textContent).toContain('of 4 workspaces selected');
|
|
86
|
+
expect(el.textContent).not.toContain('in kabmo');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('Delete is refused with nothing selected', async () => {
|
|
90
|
+
const { el, calls } = await mount({ count: 0 });
|
|
91
|
+
const del = buttonContaining(el, 'Delete');
|
|
92
|
+
expect(del?.disabled).toBe(true);
|
|
93
|
+
await act(async () => { del?.click(); });
|
|
94
|
+
expect(calls.del).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('Delete is enabled with a selection and reports its handler', async () => {
|
|
98
|
+
const { el, calls } = await mount({ count: 2 });
|
|
99
|
+
const del = buttonContaining(el, 'Delete');
|
|
100
|
+
expect(del?.disabled).toBe(false);
|
|
101
|
+
await act(async () => { del?.click(); });
|
|
102
|
+
expect(calls.del).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('Select all is refused once everything is selected', async () => {
|
|
106
|
+
const { el } = await mount({ count: 3, total: 3 });
|
|
107
|
+
expect(buttonContaining(el, 'Select all')?.disabled).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('Unselect all is refused with nothing selected', async () => {
|
|
111
|
+
const { el } = await mount({ count: 0 });
|
|
112
|
+
expect(buttonContaining(el, 'Unselect all')?.disabled).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('both directions are separate controls and both report', async () => {
|
|
116
|
+
const { el, calls } = await mount({ count: 1, total: 3 });
|
|
117
|
+
await act(async () => {
|
|
118
|
+
buttonContaining(el, 'Select all')?.click();
|
|
119
|
+
buttonContaining(el, 'Unselect all')?.click();
|
|
120
|
+
});
|
|
121
|
+
expect(calls.selectAll).toBe(1);
|
|
122
|
+
expect(calls.unselectAll).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a running delete disables every control and says so', async () => {
|
|
126
|
+
const { el } = await mount({ count: 2, busy: true });
|
|
127
|
+
expect(el.textContent).toContain('Deleting…');
|
|
128
|
+
for (const button of Array.from(el.querySelectorAll('button'))) {
|
|
129
|
+
expect((button as HTMLButtonElement).disabled).toBe(true);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The bar a batch selection raises: what is selected, select-all / unselect-all,
|
|
8
|
+
* Delete and Cancel.
|
|
9
|
+
*
|
|
10
|
+
* It sits at the bottom of the sidebar (desktop) or above the list (phone) and
|
|
11
|
+
* is the ONLY place a delete can be started — the checkboxes themselves never
|
|
12
|
+
* act, so a mis-tap on a row cannot delete anything. Select-all and unselect-all
|
|
13
|
+
* are two buttons rather than one toggle: after a partial selection the user
|
|
14
|
+
* needs both directions, and one alternating control cannot say which it means.
|
|
15
|
+
*
|
|
16
|
+
* The scope noun is the mode's, not the bar's: "Select all" on the session mode
|
|
17
|
+
* means every session of the workspace whose menu started it, and on the
|
|
18
|
+
* workspace mode every workspace on screen.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { CheckCheck, Loader2, Trash2, X } from 'lucide-preact';
|
|
22
|
+
import type { BatchMode } from '@/shared/lib/workspace/batch-select';
|
|
23
|
+
|
|
24
|
+
export interface BatchActionBarProps {
|
|
25
|
+
mode: BatchMode;
|
|
26
|
+
/** How many rows are selected. */
|
|
27
|
+
count: number;
|
|
28
|
+
/** How many the scope can offer — the denominator of "3 of 12". */
|
|
29
|
+
total: number;
|
|
30
|
+
/** True while a batch delete is in flight. */
|
|
31
|
+
busy: boolean;
|
|
32
|
+
/** The scope's name, for the sentence ("in kabmo"). Omitted in workspace mode. */
|
|
33
|
+
scopeLabel?: string;
|
|
34
|
+
onSelectAll: () => void;
|
|
35
|
+
onUnselectAll: () => void;
|
|
36
|
+
onDelete: () => void;
|
|
37
|
+
onCancel: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BatchActionBar({
|
|
41
|
+
mode,
|
|
42
|
+
count,
|
|
43
|
+
total,
|
|
44
|
+
busy,
|
|
45
|
+
scopeLabel,
|
|
46
|
+
onSelectAll,
|
|
47
|
+
onUnselectAll,
|
|
48
|
+
onDelete,
|
|
49
|
+
onCancel,
|
|
50
|
+
}: BatchActionBarProps) {
|
|
51
|
+
const noun = mode === 'workspace' ? 'workspace' : 'session';
|
|
52
|
+
const plural = count === 1 ? noun : `${noun}s`;
|
|
53
|
+
const where = mode === 'session' && scopeLabel ? ` in ${scopeLabel}` : '';
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
data-testid="batch-action-bar"
|
|
58
|
+
className="border-t border-ink/15 bg-paper px-2.5 py-2 flex flex-col gap-1.5 flex-shrink-0"
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-center justify-between gap-2 text-[11px]">
|
|
61
|
+
<span className="min-w-0 truncate text-ink/70">
|
|
62
|
+
<span className="font-semibold text-ink">{count}</span>
|
|
63
|
+
{' '}of {total} {total === 1 ? noun : `${noun}s`} selected{where}
|
|
64
|
+
</span>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onCancel}
|
|
68
|
+
disabled={busy}
|
|
69
|
+
title="Cancel selection"
|
|
70
|
+
aria-label="Cancel selection"
|
|
71
|
+
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-ink/50 hover:text-ink hover:bg-ink/10 transition-colors cursor-pointer disabled:opacity-50"
|
|
72
|
+
>
|
|
73
|
+
<X size={13} />
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="flex items-center gap-1.5">
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={onSelectAll}
|
|
81
|
+
disabled={busy || total === 0 || count === total}
|
|
82
|
+
className="flex-1 min-w-0 h-7 flex items-center justify-center gap-1 px-2 rounded border border-ink/20 text-[11px] font-medium text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-40"
|
|
83
|
+
>
|
|
84
|
+
<CheckCheck size={12} className="shrink-0" />
|
|
85
|
+
<span className="truncate">Select all</span>
|
|
86
|
+
</button>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onUnselectAll}
|
|
90
|
+
disabled={busy || count === 0}
|
|
91
|
+
className="flex-1 min-w-0 h-7 flex items-center justify-center gap-1 px-2 rounded border border-ink/20 text-[11px] font-medium text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-40"
|
|
92
|
+
>
|
|
93
|
+
<span className="truncate">Unselect all</span>
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onDelete}
|
|
98
|
+
disabled={busy || count === 0}
|
|
99
|
+
className="flex-1 min-w-0 h-7 flex items-center justify-center gap-1 px-2 rounded bg-error text-canvas text-[11px] font-semibold hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
|
|
100
|
+
>
|
|
101
|
+
{busy ? (
|
|
102
|
+
<>
|
|
103
|
+
<Loader2 size={12} className="animate-spin shrink-0" />
|
|
104
|
+
<span className="truncate">Deleting…</span>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<Trash2 size={12} className="shrink-0" />
|
|
109
|
+
<span className="truncate">
|
|
110
|
+
Delete {count > 0 ? `${count} ${plural}` : plural}
|
|
111
|
+
</span>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Confirmation for a batch delete, for both nouns.
|
|
8
|
+
*
|
|
9
|
+
* It names what actually disappears, because the two modes destroy different
|
|
10
|
+
* things and only one of them is recoverable by re-adding: deleting SESSIONS
|
|
11
|
+
* removes transcripts from disk, while deleting WORKSPACES only unregisters
|
|
12
|
+
* them (the files stay, and the folder can be added back). Saying the same
|
|
13
|
+
* sentence for both would be a lie in one direction or the other.
|
|
14
|
+
*
|
|
15
|
+
* Refusals are listed IN the dialog after the run: the server answers per-id
|
|
16
|
+
* verdicts, and a batch of twenty that removed eighteen must report the two
|
|
17
|
+
* that survived with their reasons — closing on a toast would leave the user
|
|
18
|
+
* with a count they cannot reconcile against the list.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect } from 'preact/hooks';
|
|
22
|
+
import { AlertTriangle, Loader2, Trash2 } from 'lucide-preact';
|
|
23
|
+
import { Modal } from '@/client/components/common/Modal';
|
|
24
|
+
import type { BatchDeleteOutcome } from '@/client/hooks/workspace/batch-delete';
|
|
25
|
+
import type { BatchMode } from '@/shared/lib/workspace/batch-select';
|
|
26
|
+
|
|
27
|
+
export interface BatchDeleteModalProps {
|
|
28
|
+
mode: BatchMode;
|
|
29
|
+
/** How many rows the user asked to delete; 0 closes the dialog. */
|
|
30
|
+
count: number;
|
|
31
|
+
/** The scope's name, so the sentence names the workspace. */
|
|
32
|
+
scopeLabel?: string;
|
|
33
|
+
isDeleting: boolean;
|
|
34
|
+
/** The settled outcome, once the run finished. Null while running. */
|
|
35
|
+
outcome: BatchDeleteOutcome | null;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
onConfirm: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BatchDeleteModal({
|
|
41
|
+
mode,
|
|
42
|
+
count,
|
|
43
|
+
scopeLabel,
|
|
44
|
+
isDeleting,
|
|
45
|
+
outcome,
|
|
46
|
+
onClose,
|
|
47
|
+
onConfirm,
|
|
48
|
+
}: BatchDeleteModalProps) {
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const handler = (event: globalThis.KeyboardEvent) => {
|
|
51
|
+
if (event.key !== 'Escape' || isDeleting) return;
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
onClose();
|
|
54
|
+
};
|
|
55
|
+
window.addEventListener('keydown', handler);
|
|
56
|
+
return () => window.removeEventListener('keydown', handler);
|
|
57
|
+
}, [onClose, isDeleting]);
|
|
58
|
+
|
|
59
|
+
if (count === 0) return null;
|
|
60
|
+
|
|
61
|
+
const sessions = mode === 'session';
|
|
62
|
+
const noun = sessions ? 'session' : 'workspace';
|
|
63
|
+
const plural = count === 1 ? noun : `${noun}s`;
|
|
64
|
+
const where = sessions && scopeLabel ? ` in ${scopeLabel}` : '';
|
|
65
|
+
const finished = !isDeleting && outcome !== null;
|
|
66
|
+
const removed = outcome?.removed.length ?? 0;
|
|
67
|
+
const refused = outcome?.refused ?? [];
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Modal
|
|
71
|
+
onClose={isDeleting ? () => {} : onClose}
|
|
72
|
+
zClass="z-[70]"
|
|
73
|
+
maxWidthClass="max-w-[460px]"
|
|
74
|
+
header={
|
|
75
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
76
|
+
<Trash2 size={16} className="text-error shrink-0" />
|
|
77
|
+
<div className="min-w-0">
|
|
78
|
+
<h2 className="text-sm font-semibold text-ink">
|
|
79
|
+
Delete {count} {plural}?
|
|
80
|
+
</h2>
|
|
81
|
+
<p className="text-[11px] text-ink/50 truncate">{where ? where.trim() : 'Selected rows'}</p>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
}
|
|
85
|
+
footer={
|
|
86
|
+
<div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-ink/10">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onClose}
|
|
90
|
+
disabled={isDeleting}
|
|
91
|
+
className="px-3.5 py-1.5 rounded-md border border-ink/20 text-xs font-medium text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-50"
|
|
92
|
+
>
|
|
93
|
+
{finished ? 'Close' : 'Cancel'}
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onConfirm}
|
|
98
|
+
disabled={isDeleting || finished}
|
|
99
|
+
className="px-3.5 py-1.5 rounded-md bg-error text-canvas text-xs font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 flex items-center gap-1.5"
|
|
100
|
+
>
|
|
101
|
+
{isDeleting ? (
|
|
102
|
+
<>
|
|
103
|
+
<Loader2 size={13} className="animate-spin" />
|
|
104
|
+
<span>Deleting…</span>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<>
|
|
108
|
+
<Trash2 size={13} />
|
|
109
|
+
<span>Delete {count} {plural}</span>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
<div className="px-5 py-4 space-y-3">
|
|
117
|
+
{sessions ? (
|
|
118
|
+
<ul className="text-[11.5px] leading-relaxed text-ink/70 space-y-1.5">
|
|
119
|
+
<li>
|
|
120
|
+
Each transcript is removed from{' '}
|
|
121
|
+
<span className="font-mono text-ink">~/.omp/agent/sessions</span> — the conversations are
|
|
122
|
+
gone from the sidebar and from oh-my-pi.
|
|
123
|
+
</li>
|
|
124
|
+
<li>Their subagent transcripts, side questions, queued messages and saved layout go too.</li>
|
|
125
|
+
<li>A session that is mid-run is refused rather than interrupted; stop it and delete again.</li>
|
|
126
|
+
</ul>
|
|
127
|
+
) : (
|
|
128
|
+
<ul className="text-[11.5px] leading-relaxed text-ink/70 space-y-1.5">
|
|
129
|
+
<li>The workspaces are removed from this sidebar.</li>
|
|
130
|
+
<li>
|
|
131
|
+
Their directories and every transcript on disk are left alone — re-add a workspace with{' '}
|
|
132
|
+
<span className="font-mono text-ink">New Workspace</span> when you want it back.
|
|
133
|
+
</li>
|
|
134
|
+
</ul>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{outcome?.error ? (
|
|
138
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-error/30 bg-error/5 text-[11px] text-ink/75">
|
|
139
|
+
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
|
|
140
|
+
<span>{outcome.error}</span>
|
|
141
|
+
</div>
|
|
142
|
+
) : finished ? (
|
|
143
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-ink/15 bg-ink/5 text-[11px] text-ink/75">
|
|
144
|
+
<span>
|
|
145
|
+
Removed {removed} {removed === 1 ? noun : `${noun}s`}
|
|
146
|
+
{refused.length > 0 ? `, ${refused.length} refused:` : '.'}
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-error/30 bg-error/5 text-[11px] text-ink/75">
|
|
151
|
+
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-error" />
|
|
152
|
+
<span>
|
|
153
|
+
{sessions
|
|
154
|
+
? 'This cannot be undone from the UI. Archive a session instead to keep its transcript.'
|
|
155
|
+
: 'This removes the sidebar entry only. The folder itself is not deleted.'}
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{refused.length > 0 && (
|
|
161
|
+
<ul className="max-h-40 overflow-y-auto scrollbar-overlay-container space-y-1 text-[11px] text-ink/70">
|
|
162
|
+
{refused.map((entry) => (
|
|
163
|
+
<li key={entry.id} className="flex items-start gap-1.5">
|
|
164
|
+
{/* The whole id, truncated by CSS rather than sliced: a sliced
|
|
165
|
+
`new-…` reads `new-1792`, which names nothing. */}
|
|
166
|
+
<span className="font-mono text-ink/50 shrink-0 max-w-[7rem] truncate" title={entry.id}>
|
|
167
|
+
{entry.id}
|
|
168
|
+
</span>
|
|
169
|
+
<span className="min-w-0">{entry.error}</span>
|
|
170
|
+
</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</Modal>
|
|
176
|
+
);
|
|
177
|
+
}
|