@nextclaw/ui 0.16.0 → 0.17.1
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/CHANGELOG.md +21 -0
- package/dist/assets/api-DJ6ZKWFY.js +13 -0
- package/dist/assets/{appearance-settings-page-CfwDf9ag.js → appearance-settings-page-B-juoJ6F.js} +1 -1
- package/dist/assets/{channels-list-page-Dyn83ZlS.js → channels-list-page-mVHYa7xW.js} +1 -1
- package/dist/assets/{chat-page-CL0IMvgF.js → chat-page-DsMYGNee.js} +1 -1
- package/dist/assets/{config-split-page-D4D4HFhq.js → config-split-page-BaqLcRxk.js} +1 -1
- package/dist/assets/{confirm-dialog-Cv6-9Nnj.js → confirm-dialog-DasFoaCT.js} +1 -1
- package/dist/assets/{desktop-update-config-Bckgulvm.js → desktop-update-config-CoEvKrpB.js} +1 -1
- package/dist/assets/{dist-BrRgjiq1.js → dist-C741xUwt.js} +1 -1
- package/dist/assets/{dist-Bh_tj3FN.js → dist-zOKNc7QQ.js} +1 -1
- package/dist/assets/doc-browser-BbirIYbj.js +1 -0
- package/dist/assets/{doc-browser-BlWjhmnM.js → doc-browser-Crqog5cn.js} +1 -1
- package/dist/assets/{doc-browser-context-NGYa66Xg.js → doc-browser-context-BT8NWQyM.js} +1 -1
- package/dist/assets/{form-actions-CeKgh4SQ.js → form-actions-BcukaMyR.js} +1 -1
- package/dist/assets/index-1f4Pv2q3.css +1 -0
- package/dist/assets/index-BYWLjYFQ.js +109 -0
- package/dist/assets/loader-circle-LVHz84Xj.js +1 -0
- package/dist/assets/mcp-marketplace-page-2SsrbaiQ.js +1 -0
- package/dist/assets/{mcp-marketplace-page-DfNmMqKs.js → mcp-marketplace-page-XgYLOAkK.js} +1 -1
- package/dist/assets/{model-config-page-DHzyDV5v.js → model-config-page-DL8ljJK-.js} +1 -1
- package/dist/assets/{navigation-link-CzGgQGi_.js → navigation-link-BoMNAKyw.js} +1 -1
- package/dist/assets/plus-BeGZHC3f.js +1 -0
- package/dist/assets/{provider-scoped-model-input-CjrHwzFb.js → provider-scoped-model-input-CB-vojwC.js} +1 -1
- package/dist/assets/{providers-config-page-ZPvJWHFN.js → providers-config-page-C80nti5H.js} +1 -1
- package/dist/assets/{react-BXRf96P-.js → react-DND3vEAM.js} +1 -1
- package/dist/assets/{refresh-cw-ChKuqgPR.js → refresh-cw-DZ_Y89ld.js} +1 -1
- package/dist/assets/remote-Codkr9SX.js +1 -0
- package/dist/assets/rotate-cw-CP3pQ4R8.js +1 -0
- package/dist/assets/{runtime-config-page-CGpPQD8K.js → runtime-config-page-Um1XDqXJ.js} +1 -1
- package/dist/assets/{save-BWGNAKV5.js → save-BgMfxs5u.js} +1 -1
- package/dist/assets/{search-DFcT_9wJ.js → search-C7ruoV7u.js} +1 -1
- package/dist/assets/{search-config-page-DBBzVAQZ.js → search-config-page-BTjfOZ6q.js} +1 -1
- package/dist/assets/{secrets-config-page-bnJ8sIB7.js → secrets-config-page-BFjiSz73.js} +1 -1
- package/dist/assets/security-config-Dx-wMb4q.js +1 -0
- package/dist/assets/{select-BnNrv8si.js → select-BMX2ooE-.js} +1 -1
- package/dist/assets/{settings-2-CFHrgKTX.js → settings-2-BNQF6hed.js} +1 -1
- package/dist/assets/skeleton-CWZ0IO2D.js +1 -0
- package/dist/assets/{tag-chip-L5w4xfS7.js → tag-chip-BL-UXlVO.js} +1 -1
- package/dist/assets/x-BSu1xwPK.js +1 -0
- package/dist/index.html +19 -19
- package/package.json +4 -4
- package/src/features/app-data/components/app-data-removal-choice.tsx +43 -0
- package/src/features/app-data/components/app-retained-data-section.tsx +179 -0
- package/src/features/app-data/components/app-storage-usage.tsx +59 -0
- package/src/features/app-data/hooks/use-app-data.ts +26 -0
- package/src/features/app-data/index.ts +8 -0
- package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +114 -10
- package/src/features/apps/components/__tests__/apps-panel.test.tsx +18 -2
- package/src/features/apps/components/app-package-card.tsx +98 -53
- package/src/features/apps/components/app-packages-panel.tsx +78 -23
- package/src/features/apps/components/apps-panel.tsx +14 -3
- package/src/features/apps/hooks/use-app-packages.ts +7 -2
- package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +5 -0
- package/src/features/apps/managers/app-package-operation-settlement.manager.ts +8 -2
- package/src/features/service-apps/components/__tests__/service-apps-panel.test.tsx +91 -6
- package/src/features/service-apps/components/service-app-delete-dialog.tsx +122 -0
- package/src/features/service-apps/components/service-app-list-item.tsx +50 -21
- package/src/features/service-apps/components/service-apps-panel.tsx +38 -2
- package/src/features/service-apps/hooks/use-service-apps.ts +4 -1
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +26 -1
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +26 -1
- package/dist/assets/api-Dket1cmq.js +0 -13
- package/dist/assets/doc-browser-Da9hDsoQ.js +0 -1
- package/dist/assets/index-Bz5yNMXA.js +0 -109
- package/dist/assets/index-DSLK7zgR.css +0 -1
- package/dist/assets/loader-circle-DLp2jNIf.js +0 -1
- package/dist/assets/mcp-marketplace-page-CIWbjXM8.js +0 -1
- package/dist/assets/plus-Dfj3tJ_m.js +0 -1
- package/dist/assets/remote-C2enAZbs.js +0 -1
- package/dist/assets/rotate-cw-BGkZpcc7.js +0 -1
- package/dist/assets/security-config-CfbXSpgX.js +0 -1
- package/dist/assets/skeleton-tfWhGuJT.js +0 -1
- package/dist/assets/x-BCSE3LCQ.js +0 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AppDataEntry } from '@nextclaw/client-sdk';
|
|
2
|
+
import { HardDrive } from 'lucide-react';
|
|
3
|
+
import { t } from '@/shared/lib/i18n';
|
|
4
|
+
|
|
5
|
+
type Storage = AppDataEntry['storage'];
|
|
6
|
+
type Usage = AppDataEntry['usage'];
|
|
7
|
+
|
|
8
|
+
export function AppStorageUsageDetails({
|
|
9
|
+
storage,
|
|
10
|
+
usage,
|
|
11
|
+
}: {
|
|
12
|
+
storage: Storage;
|
|
13
|
+
usage: Usage;
|
|
14
|
+
}) {
|
|
15
|
+
const rows = [
|
|
16
|
+
[t('appDataClassData'), usage.dataBytes],
|
|
17
|
+
[t('appDataClassConfig'), usage.configBytes],
|
|
18
|
+
[t('appDataClassState'), usage.stateBytes],
|
|
19
|
+
[t('appDataClassCache'), usage.cacheBytes],
|
|
20
|
+
[t('appDataClassTemporary'), usage.temporaryBytes],
|
|
21
|
+
[t('appDataClassLogs'), usage.logsBytes],
|
|
22
|
+
] as const;
|
|
23
|
+
return (
|
|
24
|
+
<div className="rounded-xl bg-muted/45 px-3 py-2.5">
|
|
25
|
+
<div className="flex items-center justify-between gap-3 text-xs">
|
|
26
|
+
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
|
27
|
+
<HardDrive className="h-3.5 w-3.5" />
|
|
28
|
+
{t('appDataStorageTotal')}
|
|
29
|
+
</span>
|
|
30
|
+
<span className="tabular-nums text-foreground">{formatBytes(usage.totalBytes)}</span>
|
|
31
|
+
</div>
|
|
32
|
+
<dl className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-3">
|
|
33
|
+
{rows.map(([label, bytes]) => (
|
|
34
|
+
<div key={label} className="flex min-w-0 items-center justify-between gap-2">
|
|
35
|
+
<dt className="truncate text-muted-foreground">{label}</dt>
|
|
36
|
+
<dd className="shrink-0 tabular-nums text-foreground/80">{formatBytes(bytes)}</dd>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</dl>
|
|
40
|
+
<div className="mt-2 border-t border-border/50 pt-2">
|
|
41
|
+
<div className="text-[10px] text-muted-foreground">{t('appDataStoragePath')}</div>
|
|
42
|
+
<code
|
|
43
|
+
aria-label={t('appDataStoragePath')}
|
|
44
|
+
className="mt-0.5 block select-all break-all font-mono text-[10px] leading-4 text-foreground/75"
|
|
45
|
+
tabIndex={0}
|
|
46
|
+
>
|
|
47
|
+
{storage.instanceDirectory}
|
|
48
|
+
</code>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatBytes(value: number): string {
|
|
55
|
+
if (value < 1024) return `${value} B`;
|
|
56
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
57
|
+
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
58
|
+
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { toast } from 'sonner';
|
|
3
|
+
import { nextclawClient } from '@/shared/lib/api';
|
|
4
|
+
import { t } from '@/shared/lib/i18n';
|
|
5
|
+
|
|
6
|
+
export const APP_DATA_QUERY_KEY = ['app-data'] as const;
|
|
7
|
+
|
|
8
|
+
export function useAppData() {
|
|
9
|
+
return useQuery({
|
|
10
|
+
queryKey: APP_DATA_QUERY_KEY,
|
|
11
|
+
queryFn: () => nextclawClient.appData.list(),
|
|
12
|
+
staleTime: 0,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useDeleteRetainedAppData() {
|
|
17
|
+
const queryClient = useQueryClient();
|
|
18
|
+
return useMutation({
|
|
19
|
+
mutationFn: ({ dataId, confirmAppId }: { dataId: string; confirmAppId: string }) =>
|
|
20
|
+
nextclawClient.appData.deleteRetained(dataId, confirmAppId),
|
|
21
|
+
onSuccess: async () => {
|
|
22
|
+
await queryClient.invalidateQueries({ queryKey: APP_DATA_QUERY_KEY });
|
|
23
|
+
toast.success(t('appDataDeleteCompleted'));
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { AppDataRemovalChoice } from './components/app-data-removal-choice';
|
|
2
|
+
export { AppRetainedDataSection } from './components/app-retained-data-section';
|
|
3
|
+
export { AppStorageUsageDetails, formatBytes } from './components/app-storage-usage';
|
|
4
|
+
export {
|
|
5
|
+
APP_DATA_QUERY_KEY,
|
|
6
|
+
useAppData,
|
|
7
|
+
useDeleteRetainedAppData,
|
|
8
|
+
} from './hooks/use-app-data';
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import type { AppPackageOperationView } from '@nextclaw/client-sdk';
|
|
4
4
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { AppPackagesPanel } from '@/features/apps/components/app-packages-panel';
|
|
6
6
|
|
|
7
7
|
const mocks = vi.hoisted(() => ({
|
|
8
|
+
appDataError: false,
|
|
8
9
|
enabled: false,
|
|
10
|
+
includePackageStorageUsage: true,
|
|
9
11
|
grantClient: vi.fn(),
|
|
10
12
|
lifecycleMutate: vi.fn(),
|
|
11
13
|
lifecycleReset: vi.fn(),
|
|
@@ -15,9 +17,30 @@ const mocks = vi.hoisted(() => ({
|
|
|
15
17
|
refetchOperations: vi.fn(),
|
|
16
18
|
refetchPackages: vi.fn(),
|
|
17
19
|
refetchPanels: vi.fn(),
|
|
20
|
+
refetchAppData: vi.fn(),
|
|
21
|
+
deleteRetained: vi.fn(),
|
|
22
|
+
deleteRetainedReset: vi.fn(),
|
|
23
|
+
retainedEntries: [] as Array<Record<string, unknown>>,
|
|
18
24
|
requestAuthorization: vi.fn(async () => true),
|
|
19
25
|
}));
|
|
20
26
|
|
|
27
|
+
vi.mock('@/features/app-data/hooks/use-app-data', () => ({
|
|
28
|
+
useAppData: () => ({
|
|
29
|
+
data: { entries: mocks.retainedEntries, diagnostics: [] },
|
|
30
|
+
error: mocks.appDataError ? new Error('App data unavailable') : null,
|
|
31
|
+
isError: mocks.appDataError,
|
|
32
|
+
isLoading: false,
|
|
33
|
+
refetch: mocks.refetchAppData,
|
|
34
|
+
}),
|
|
35
|
+
useDeleteRetainedAppData: () => ({
|
|
36
|
+
error: null,
|
|
37
|
+
isError: false,
|
|
38
|
+
isPending: false,
|
|
39
|
+
mutate: mocks.deleteRetained,
|
|
40
|
+
reset: mocks.deleteRetainedReset,
|
|
41
|
+
}),
|
|
42
|
+
}));
|
|
43
|
+
|
|
21
44
|
vi.mock('@/app/components/app-presenter-provider', () => ({
|
|
22
45
|
useAppPresenter: () => ({
|
|
23
46
|
appPackageOperationSettlementManager: { settle: vi.fn() },
|
|
@@ -97,15 +120,7 @@ vi.mock('@/features/apps/hooks/use-app-packages', () => ({
|
|
|
97
120
|
primaryPanelId: 'nextclaw-personal-organizer-todos',
|
|
98
121
|
runtimeProfile: 'native-process',
|
|
99
122
|
storage: createStorageFixture(),
|
|
100
|
-
storageUsage:
|
|
101
|
-
dataBytes: 128,
|
|
102
|
-
configBytes: 0,
|
|
103
|
-
stateBytes: 0,
|
|
104
|
-
cacheBytes: 0,
|
|
105
|
-
temporaryBytes: 0,
|
|
106
|
-
logsBytes: 0,
|
|
107
|
-
totalBytes: 128,
|
|
108
|
-
},
|
|
123
|
+
storageUsage: mocks.includePackageStorageUsage ? createUsageFixture() : undefined,
|
|
109
124
|
}],
|
|
110
125
|
},
|
|
111
126
|
error: null,
|
|
@@ -130,6 +145,18 @@ function createStorageFixture() {
|
|
|
130
145
|
};
|
|
131
146
|
}
|
|
132
147
|
|
|
148
|
+
function createUsageFixture() {
|
|
149
|
+
return {
|
|
150
|
+
dataBytes: 128,
|
|
151
|
+
configBytes: 3,
|
|
152
|
+
stateBytes: 2,
|
|
153
|
+
cacheBytes: 1,
|
|
154
|
+
temporaryBytes: 0,
|
|
155
|
+
logsBytes: 4,
|
|
156
|
+
totalBytes: 138,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
133
160
|
vi.mock('@/features/apps/hooks/use-app-marketplace', () => ({
|
|
134
161
|
useAppMarketplace: () => ({
|
|
135
162
|
data: {
|
|
@@ -217,7 +244,9 @@ vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
|
|
|
217
244
|
|
|
218
245
|
describe('AppPackagesPanel', () => {
|
|
219
246
|
beforeEach(() => {
|
|
247
|
+
mocks.appDataError = false;
|
|
220
248
|
mocks.enabled = false;
|
|
249
|
+
mocks.includePackageStorageUsage = true;
|
|
221
250
|
mocks.grantClient.mockReset();
|
|
222
251
|
mocks.lifecycleMutate.mockReset();
|
|
223
252
|
mocks.lifecycleReset.mockReset();
|
|
@@ -227,6 +256,10 @@ describe('AppPackagesPanel', () => {
|
|
|
227
256
|
mocks.refetchPackages.mockReset();
|
|
228
257
|
mocks.refetchOperations.mockReset();
|
|
229
258
|
mocks.refetchPanels.mockReset();
|
|
259
|
+
mocks.refetchAppData.mockReset();
|
|
260
|
+
mocks.deleteRetained.mockReset();
|
|
261
|
+
mocks.deleteRetainedReset.mockReset();
|
|
262
|
+
mocks.retainedEntries = [];
|
|
230
263
|
mocks.requestAuthorization.mockReset();
|
|
231
264
|
mocks.requestAuthorization.mockResolvedValue(true);
|
|
232
265
|
});
|
|
@@ -248,6 +281,30 @@ describe('AppPackagesPanel', () => {
|
|
|
248
281
|
);
|
|
249
282
|
});
|
|
250
283
|
|
|
284
|
+
it('scrolls and transfers focus to a package selected from Service Apps', async () => {
|
|
285
|
+
render(
|
|
286
|
+
<AppPackagesPanel
|
|
287
|
+
focusedPackageId="nextclaw.personal-organizer"
|
|
288
|
+
onOpenPanelApp={mocks.onOpen}
|
|
289
|
+
/>,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const packageCard = screen.getByRole('region', { name: 'Personal Space' });
|
|
293
|
+
await waitFor(() => expect(document.activeElement).toBe(packageCard));
|
|
294
|
+
expect(packageCard.className).toContain('ring-2');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('distinguishes an untouched app from an App Data query failure', () => {
|
|
298
|
+
mocks.includePackageStorageUsage = false;
|
|
299
|
+
const view = render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
|
|
300
|
+
expect(screen.getByTitle('/tmp/data').textContent).toContain('No data yet');
|
|
301
|
+
|
|
302
|
+
mocks.appDataError = true;
|
|
303
|
+
view.rerender(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
|
|
304
|
+
|
|
305
|
+
expect(screen.getByTitle('/tmp/data').textContent).toContain('Size unavailable');
|
|
306
|
+
});
|
|
307
|
+
|
|
251
308
|
it('opens an enabled package panel through the existing panel app chain', async () => {
|
|
252
309
|
const user = userEvent.setup();
|
|
253
310
|
mocks.enabled = true;
|
|
@@ -300,8 +357,13 @@ describe('AppPackagesPanel', () => {
|
|
|
300
357
|
await user.click(screen.getByRole('button', { name: 'More app actions' }));
|
|
301
358
|
await user.click(screen.getByRole('button', { name: 'Uninstall' }));
|
|
302
359
|
expect(screen.getByRole('heading', { name: 'Uninstall this app?' })).toBeTruthy();
|
|
360
|
+
await waitFor(() => {
|
|
361
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
|
|
362
|
+
});
|
|
303
363
|
expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
|
|
304
364
|
.toBe('true');
|
|
365
|
+
expect(screen.getByText('Total app data')).toBeTruthy();
|
|
366
|
+
expect(screen.getByText('Managed instance path')).toBeTruthy();
|
|
305
367
|
|
|
306
368
|
await user.click(screen.getByRole('button', { name: 'Uninstall' }));
|
|
307
369
|
expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
|
|
@@ -310,6 +372,48 @@ describe('AppPackagesPanel', () => {
|
|
|
310
372
|
);
|
|
311
373
|
});
|
|
312
374
|
|
|
375
|
+
it('shows retained data and requires a dedicated permanent-delete confirmation', async () => {
|
|
376
|
+
const user = userEvent.setup();
|
|
377
|
+
mocks.retainedEntries = [{
|
|
378
|
+
id: 'ad1.encoded',
|
|
379
|
+
appId: 'example.notes',
|
|
380
|
+
instanceId: 'default',
|
|
381
|
+
displayName: 'Notes',
|
|
382
|
+
source: 'package',
|
|
383
|
+
lifecycle: 'retained',
|
|
384
|
+
storage: createStorageFixture(),
|
|
385
|
+
usage: {
|
|
386
|
+
dataBytes: 128,
|
|
387
|
+
configBytes: 3,
|
|
388
|
+
stateBytes: 2,
|
|
389
|
+
cacheBytes: 1,
|
|
390
|
+
temporaryBytes: 0,
|
|
391
|
+
logsBytes: 4,
|
|
392
|
+
totalBytes: 138,
|
|
393
|
+
},
|
|
394
|
+
createdAt: '2026-08-14T00:00:00.000Z',
|
|
395
|
+
actions: { deleteRetainedData: true },
|
|
396
|
+
}];
|
|
397
|
+
const view = render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
|
|
398
|
+
|
|
399
|
+
expect(screen.getByRole('heading', { name: 'Saved app data' })).toBeTruthy();
|
|
400
|
+
await user.click(screen.getByRole('button', { name: 'Delete data' }));
|
|
401
|
+
expect(screen.getByRole('heading', { name: 'Permanently delete this app data?' })).toBeTruthy();
|
|
402
|
+
|
|
403
|
+
await user.click(screen.getByRole('button', { name: 'Permanently delete data' }));
|
|
404
|
+
expect(mocks.deleteRetained).toHaveBeenCalledWith(
|
|
405
|
+
{ dataId: 'ad1.encoded', confirmAppId: 'example.notes' },
|
|
406
|
+
{ onSuccess: expect.any(Function) },
|
|
407
|
+
);
|
|
408
|
+
const onSuccess = mocks.deleteRetained.mock.calls[0]?.[1].onSuccess as () => void;
|
|
409
|
+
mocks.retainedEntries = [];
|
|
410
|
+
view.rerender(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
|
|
411
|
+
await act(async () => onSuccess());
|
|
412
|
+
await waitFor(() => {
|
|
413
|
+
expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'Your apps' }));
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
313
417
|
it('keeps an accepted install visible in the library and marketplace while it runs', async () => {
|
|
314
418
|
const user = userEvent.setup();
|
|
315
419
|
mocks.operations = [{
|
|
@@ -6,13 +6,19 @@ import { AppsPanel, type AppsPanelTab } from '@/features/apps/components/apps-pa
|
|
|
6
6
|
|
|
7
7
|
vi.mock('@/shared/lib/i18n', () => ({ t: (key: string) => key }));
|
|
8
8
|
vi.mock('@/features/apps/components/app-packages-panel', () => ({
|
|
9
|
-
AppPackagesPanel: () =>
|
|
9
|
+
AppPackagesPanel: ({ focusedPackageId }: { focusedPackageId?: string }) => (
|
|
10
|
+
<div>packages-content:{focusedPackageId ?? 'none'}</div>
|
|
11
|
+
),
|
|
10
12
|
}));
|
|
11
13
|
vi.mock('@/features/panel-apps', () => ({
|
|
12
14
|
PanelAppsList: () => <div>panel-apps-content</div>,
|
|
13
15
|
}));
|
|
14
16
|
vi.mock('@/features/service-apps', () => ({
|
|
15
|
-
ServiceAppsPanel: () =>
|
|
17
|
+
ServiceAppsPanel: ({ onManagePackage }: { onManagePackage: (packageId: string) => void }) => (
|
|
18
|
+
<button type="button" onClick={() => onManagePackage('nextclaw.personal-organizer')}>
|
|
19
|
+
service-apps-content
|
|
20
|
+
</button>
|
|
21
|
+
),
|
|
16
22
|
}));
|
|
17
23
|
|
|
18
24
|
function AppsPanelHarness() {
|
|
@@ -55,4 +61,14 @@ describe('AppsPanel', () => {
|
|
|
55
61
|
expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'panelAppsTitle' }));
|
|
56
62
|
expect(screen.getByText('panel-apps-content')).toBeTruthy();
|
|
57
63
|
});
|
|
64
|
+
|
|
65
|
+
it('focuses the owning package when routing from a package-managed service', async () => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
render(<AppsPanelHarness />);
|
|
68
|
+
|
|
69
|
+
await user.click(screen.getByRole('tab', { name: 'serviceAppsTitle' }));
|
|
70
|
+
await user.click(screen.getByRole('button', { name: 'service-apps-content' }));
|
|
71
|
+
|
|
72
|
+
expect(screen.getByText('packages-content:nextclaw.personal-organizer')).toBeTruthy();
|
|
73
|
+
});
|
|
58
74
|
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import type {
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
AppDataEntry,
|
|
4
|
+
AppPackageOperationView,
|
|
5
|
+
AppPackageView,
|
|
6
|
+
} from '@nextclaw/client-sdk';
|
|
3
7
|
import {
|
|
4
8
|
AlertCircle,
|
|
5
9
|
AppWindow,
|
|
6
10
|
Bookmark,
|
|
7
11
|
CalendarDays,
|
|
8
|
-
Check,
|
|
9
12
|
CheckSquare2,
|
|
10
13
|
ChevronRight,
|
|
11
14
|
MoreHorizontal,
|
|
@@ -21,6 +24,11 @@ import {
|
|
|
21
24
|
type LucideIcon,
|
|
22
25
|
} from 'lucide-react';
|
|
23
26
|
import { AppArtwork } from '@/features/apps/components/app-artwork';
|
|
27
|
+
import {
|
|
28
|
+
AppDataRemovalChoice,
|
|
29
|
+
AppStorageUsageDetails,
|
|
30
|
+
formatBytes,
|
|
31
|
+
} from '@/features/app-data';
|
|
24
32
|
import { isAppPackageOperationActive } from '@/features/apps/hooks/use-app-packages';
|
|
25
33
|
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
26
34
|
import { Button } from '@/shared/components/ui/button';
|
|
@@ -48,6 +56,9 @@ export function AppPackageCard({
|
|
|
48
56
|
onUninstall,
|
|
49
57
|
onUpdate,
|
|
50
58
|
panelApps,
|
|
59
|
+
storageUsage,
|
|
60
|
+
storageUsageLoading,
|
|
61
|
+
storageUsageUnavailable,
|
|
51
62
|
}: {
|
|
52
63
|
appPackage: AppPackageView;
|
|
53
64
|
isPending: boolean;
|
|
@@ -59,10 +70,15 @@ export function AppPackageCard({
|
|
|
59
70
|
onUninstall: (purgeData: boolean) => void;
|
|
60
71
|
onUpdate: () => void;
|
|
61
72
|
panelApps: PanelAppEntryView[];
|
|
73
|
+
storageUsage?: AppDataEntry['usage'];
|
|
74
|
+
storageUsageLoading: boolean;
|
|
75
|
+
storageUsageUnavailable: boolean;
|
|
62
76
|
}) {
|
|
63
77
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
64
78
|
const [uninstallOpen, setUninstallOpen] = useState(false);
|
|
65
79
|
const [purgeData, setPurgeData] = useState(false);
|
|
80
|
+
const skipMenuFocusRestoreRef = useRef(false);
|
|
81
|
+
const uninstallCancelRef = useRef<HTMLButtonElement>(null);
|
|
66
82
|
const panelComponents = appPackage.components.filter((component) => component.kind === 'panel');
|
|
67
83
|
const serviceCount = appPackage.components.length - panelComponents.length;
|
|
68
84
|
const rollbackVersions = appPackage.installedVersions.filter(
|
|
@@ -75,6 +91,11 @@ export function AppPackageCard({
|
|
|
75
91
|
);
|
|
76
92
|
const operationActive = operation ? isAppPackageOperationActive(operation.status) : false;
|
|
77
93
|
const pending = isPending || operationActive;
|
|
94
|
+
const storageUsageLabel = resolveStorageUsageLabel(
|
|
95
|
+
storageUsage,
|
|
96
|
+
storageUsageLoading,
|
|
97
|
+
storageUsageUnavailable,
|
|
98
|
+
);
|
|
78
99
|
|
|
79
100
|
return (
|
|
80
101
|
<Card surface="flat" hover={false} className="overflow-hidden border-border/60 shadow-[0_1px_2px_rgba(0,0,0,0.025)]">
|
|
@@ -126,7 +147,8 @@ export function AppPackageCard({
|
|
|
126
147
|
>
|
|
127
148
|
<HardDrive className="h-3 w-3 shrink-0" />
|
|
128
149
|
<span className="shrink-0">
|
|
129
|
-
{t('appPackagesDataUsage')}
|
|
150
|
+
{t('appPackagesDataUsage')}{' '}
|
|
151
|
+
{storageUsageLabel}
|
|
130
152
|
</span>
|
|
131
153
|
<code className="min-w-0 truncate font-mono text-[10px]">
|
|
132
154
|
{appPackage.storage.dataDirectory}
|
|
@@ -160,7 +182,16 @@ export function AppPackageCard({
|
|
|
160
182
|
<MoreHorizontal className="h-4 w-4" />
|
|
161
183
|
</button>
|
|
162
184
|
</PopoverTrigger>
|
|
163
|
-
<PopoverContent
|
|
185
|
+
<PopoverContent
|
|
186
|
+
align="end"
|
|
187
|
+
className="w-52 rounded-xl p-1.5"
|
|
188
|
+
onCloseAutoFocus={(event) => {
|
|
189
|
+
if (skipMenuFocusRestoreRef.current) {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
skipMenuFocusRestoreRef.current = false;
|
|
192
|
+
}
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
164
195
|
<AppPackageMenuItem
|
|
165
196
|
icon={RefreshCw}
|
|
166
197
|
label={t('appPackagesCheckUpdate')}
|
|
@@ -185,7 +216,9 @@ export function AppPackageCard({
|
|
|
185
216
|
icon={Trash2}
|
|
186
217
|
label={t('appPackagesUninstall')}
|
|
187
218
|
onClick={() => {
|
|
219
|
+
skipMenuFocusRestoreRef.current = true;
|
|
188
220
|
setMenuOpen(false);
|
|
221
|
+
setPurgeData(false);
|
|
189
222
|
setUninstallOpen(true);
|
|
190
223
|
}}
|
|
191
224
|
/>
|
|
@@ -239,19 +272,25 @@ export function AppPackageCard({
|
|
|
239
272
|
) : null}
|
|
240
273
|
|
|
241
274
|
<Dialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
|
|
242
|
-
<DialogContent
|
|
275
|
+
<DialogContent
|
|
276
|
+
className="max-w-md [&>:last-child]:hidden"
|
|
277
|
+
onOpenAutoFocus={(event) => {
|
|
278
|
+
event.preventDefault();
|
|
279
|
+
uninstallCancelRef.current?.focus();
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
243
282
|
<DialogHeader>
|
|
244
283
|
<DialogTitle>{t('appPackagesUninstallTitle')}</DialogTitle>
|
|
245
284
|
<DialogDescription>{t('appPackagesUninstallDescription')}</DialogDescription>
|
|
246
285
|
</DialogHeader>
|
|
247
286
|
<div className="mt-4 grid gap-2">
|
|
248
|
-
<
|
|
287
|
+
<AppDataRemovalChoice
|
|
249
288
|
checked={!purgeData}
|
|
250
289
|
description={t('appPackagesKeepDataDescription')}
|
|
251
290
|
label={t('appPackagesKeepData')}
|
|
252
291
|
onClick={() => setPurgeData(false)}
|
|
253
292
|
/>
|
|
254
|
-
<
|
|
293
|
+
<AppDataRemovalChoice
|
|
255
294
|
checked={purgeData}
|
|
256
295
|
destructive
|
|
257
296
|
description={t('appPackagesDeleteDataDescription')}
|
|
@@ -259,8 +298,22 @@ export function AppPackageCard({
|
|
|
259
298
|
onClick={() => setPurgeData(true)}
|
|
260
299
|
/>
|
|
261
300
|
</div>
|
|
301
|
+
<div className="mt-3">
|
|
302
|
+
<AppPackageStorageUsage
|
|
303
|
+
loading={storageUsageLoading}
|
|
304
|
+
storage={appPackage.storage}
|
|
305
|
+
usage={storageUsage}
|
|
306
|
+
unavailable={storageUsageUnavailable}
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
262
309
|
<DialogFooter className="mt-5 gap-2 sm:gap-0">
|
|
263
|
-
<Button
|
|
310
|
+
<Button
|
|
311
|
+
ref={uninstallCancelRef}
|
|
312
|
+
autoFocus
|
|
313
|
+
type="button"
|
|
314
|
+
variant="outline"
|
|
315
|
+
onClick={() => setUninstallOpen(false)}
|
|
316
|
+
>
|
|
264
317
|
{t('cancel')}
|
|
265
318
|
</Button>
|
|
266
319
|
<Button
|
|
@@ -280,6 +333,42 @@ export function AppPackageCard({
|
|
|
280
333
|
);
|
|
281
334
|
}
|
|
282
335
|
|
|
336
|
+
function AppPackageStorageUsage({
|
|
337
|
+
loading,
|
|
338
|
+
storage,
|
|
339
|
+
unavailable,
|
|
340
|
+
usage,
|
|
341
|
+
}: {
|
|
342
|
+
loading: boolean;
|
|
343
|
+
storage: AppPackageView['storage'];
|
|
344
|
+
unavailable: boolean;
|
|
345
|
+
usage?: AppDataEntry['usage'];
|
|
346
|
+
}) {
|
|
347
|
+
if (usage) {
|
|
348
|
+
return <AppStorageUsageDetails storage={storage} usage={usage} />;
|
|
349
|
+
}
|
|
350
|
+
return (
|
|
351
|
+
<div className="rounded-xl bg-muted/45 px-3 py-3 text-xs text-muted-foreground">
|
|
352
|
+
{loading ? (
|
|
353
|
+
<LoaderCircle className="mr-1.5 inline h-3.5 w-3.5 animate-spin" />
|
|
354
|
+
) : null}
|
|
355
|
+
{t(loading
|
|
356
|
+
? 'appPackagesLoading'
|
|
357
|
+
: unavailable ? 'appPackagesDataUsageUnavailable' : 'appPackagesNoDataYet')}
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function resolveStorageUsageLabel(
|
|
363
|
+
usage: AppDataEntry['usage'] | undefined,
|
|
364
|
+
loading: boolean,
|
|
365
|
+
unavailable: boolean,
|
|
366
|
+
): string {
|
|
367
|
+
if (usage) return formatBytes(usage.totalBytes);
|
|
368
|
+
if (loading) return t('appPackagesLoading');
|
|
369
|
+
return t(unavailable ? 'appPackagesDataUsageUnavailable' : 'appPackagesNoDataYet');
|
|
370
|
+
}
|
|
371
|
+
|
|
283
372
|
function renderPrimaryActionLabel({
|
|
284
373
|
enabled,
|
|
285
374
|
isPending,
|
|
@@ -303,50 +392,6 @@ function renderPrimaryActionLabel({
|
|
|
303
392
|
return enabled ? t('appPackagesDisable') : t('appPackagesEnable');
|
|
304
393
|
}
|
|
305
394
|
|
|
306
|
-
function formatBytes(value: number): string {
|
|
307
|
-
if (value < 1024) return `${value} B`;
|
|
308
|
-
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
309
|
-
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
310
|
-
return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function DataChoice({
|
|
314
|
-
checked,
|
|
315
|
-
description,
|
|
316
|
-
destructive = false,
|
|
317
|
-
label,
|
|
318
|
-
onClick,
|
|
319
|
-
}: {
|
|
320
|
-
checked: boolean;
|
|
321
|
-
description: string;
|
|
322
|
-
destructive?: boolean;
|
|
323
|
-
label: string;
|
|
324
|
-
onClick: () => void;
|
|
325
|
-
}) {
|
|
326
|
-
return (
|
|
327
|
-
<button
|
|
328
|
-
type="button"
|
|
329
|
-
aria-pressed={checked}
|
|
330
|
-
onClick={onClick}
|
|
331
|
-
className={cn(
|
|
332
|
-
'flex items-start gap-3 rounded-xl border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border',
|
|
333
|
-
checked ? 'border-foreground/25 bg-muted/55' : 'border-border/60 hover:bg-muted/35',
|
|
334
|
-
)}
|
|
335
|
-
>
|
|
336
|
-
<span className={cn(
|
|
337
|
-
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
|
|
338
|
-
checked ? 'border-foreground bg-foreground text-background' : 'border-border',
|
|
339
|
-
)}>
|
|
340
|
-
{checked ? <Check className="h-2.5 w-2.5" /> : null}
|
|
341
|
-
</span>
|
|
342
|
-
<span>
|
|
343
|
-
<span className={cn('block text-sm font-medium', destructive ? 'text-destructive' : 'text-foreground')}>{label}</span>
|
|
344
|
-
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{description}</span>
|
|
345
|
-
</span>
|
|
346
|
-
</button>
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
395
|
function AppPackageMenuItem({
|
|
351
396
|
destructive = false,
|
|
352
397
|
icon: Icon,
|