@nextclaw/ui 0.16.0 → 0.17.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/CHANGELOG.md +13 -0
- package/dist/assets/api-CVjdSvJk.js +13 -0
- package/dist/assets/{appearance-settings-page-CfwDf9ag.js → appearance-settings-page-BOorfaUx.js} +1 -1
- package/dist/assets/{channels-list-page-Dyn83ZlS.js → channels-list-page-DuZJvYB6.js} +1 -1
- package/dist/assets/{chat-page-CL0IMvgF.js → chat-page-8PTMgI0t.js} +1 -1
- package/dist/assets/{config-split-page-D4D4HFhq.js → config-split-page-UuhsVcQD.js} +1 -1
- package/dist/assets/{confirm-dialog-Cv6-9Nnj.js → confirm-dialog-Bnosa8UF.js} +1 -1
- package/dist/assets/{desktop-update-config-Bckgulvm.js → desktop-update-config-CM4lsX4R.js} +1 -1
- package/dist/assets/{dist-BrRgjiq1.js → dist-D17RZAsz.js} +1 -1
- package/dist/assets/{dist-Bh_tj3FN.js → dist-_zZ8P38T.js} +1 -1
- package/dist/assets/{doc-browser-BlWjhmnM.js → doc-browser-CiLvybSC.js} +1 -1
- package/dist/assets/{doc-browser-context-NGYa66Xg.js → doc-browser-context-CerkiK_N.js} +1 -1
- package/dist/assets/doc-browser-z7vdH3Ez.js +1 -0
- package/dist/assets/{form-actions-CeKgh4SQ.js → form-actions-Cmv23dN4.js} +1 -1
- package/dist/assets/index-CWdgBpSc.css +1 -0
- package/dist/assets/index-DlL8uKXu.js +109 -0
- package/dist/assets/loader-circle-vUsFtTfb.js +1 -0
- package/dist/assets/mcp-marketplace-page-BV8WB3_L.js +1 -0
- package/dist/assets/{mcp-marketplace-page-DfNmMqKs.js → mcp-marketplace-page-DN_fqcLd.js} +1 -1
- package/dist/assets/{model-config-page-DHzyDV5v.js → model-config-page-BxaeCMbJ.js} +1 -1
- package/dist/assets/{navigation-link-CzGgQGi_.js → navigation-link-k9rsmtC4.js} +1 -1
- package/dist/assets/plus-Dc760kHj.js +1 -0
- package/dist/assets/{provider-scoped-model-input-CjrHwzFb.js → provider-scoped-model-input-DLSAB6a5.js} +1 -1
- package/dist/assets/{providers-config-page-ZPvJWHFN.js → providers-config-page-DM5N0aeS.js} +1 -1
- package/dist/assets/{react-BXRf96P-.js → react-BgRLh3ee.js} +1 -1
- package/dist/assets/{refresh-cw-ChKuqgPR.js → refresh-cw-axvJLmRG.js} +1 -1
- package/dist/assets/remote-Cj1GBMaN.js +1 -0
- package/dist/assets/rotate-cw-CFByP2O8.js +1 -0
- package/dist/assets/{runtime-config-page-CGpPQD8K.js → runtime-config-page-WNzZyjMW.js} +1 -1
- package/dist/assets/{save-BWGNAKV5.js → save-DbR4SGk8.js} +1 -1
- package/dist/assets/{search-DFcT_9wJ.js → search-M9lrb8id.js} +1 -1
- package/dist/assets/{search-config-page-DBBzVAQZ.js → search-config-page-DmsID8FQ.js} +1 -1
- package/dist/assets/{secrets-config-page-bnJ8sIB7.js → secrets-config-page-C10OwI0b.js} +1 -1
- package/dist/assets/security-config-CvU5JxDT.js +1 -0
- package/dist/assets/{select-BnNrv8si.js → select-D_YBzMdv.js} +1 -1
- package/dist/assets/{settings-2-CFHrgKTX.js → settings-2-zy40UwTo.js} +1 -1
- package/dist/assets/skeleton-BmAtzjDM.js +1 -0
- package/dist/assets/{tag-chip-L5w4xfS7.js → tag-chip-D7QRdcph.js} +1 -1
- package/dist/assets/x-D9Y9hf_r.js +1 -0
- package/dist/index.html +19 -19
- package/package.json +6 -6
- 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 +72 -1
- package/src/features/apps/components/app-package-card.tsx +42 -51
- package/src/features/apps/components/app-packages-panel.tsx +19 -2
- package/src/features/apps/hooks/use-app-packages.ts +2 -0
- package/src/features/service-apps/components/__tests__/service-apps-panel.test.tsx +59 -2
- package/src/features/service-apps/components/service-app-delete-dialog.tsx +122 -0
- package/src/features/service-apps/components/service-app-list-item.tsx +24 -9
- package/src/features/service-apps/components/service-apps-panel.tsx +15 -1
- package/src/features/service-apps/hooks/use-service-apps.ts +4 -1
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +23 -1
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +23 -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,4 +1,4 @@
|
|
|
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';
|
|
@@ -15,9 +15,29 @@ const mocks = vi.hoisted(() => ({
|
|
|
15
15
|
refetchOperations: vi.fn(),
|
|
16
16
|
refetchPackages: vi.fn(),
|
|
17
17
|
refetchPanels: vi.fn(),
|
|
18
|
+
refetchAppData: vi.fn(),
|
|
19
|
+
deleteRetained: vi.fn(),
|
|
20
|
+
deleteRetainedReset: vi.fn(),
|
|
21
|
+
retainedEntries: [] as Array<Record<string, unknown>>,
|
|
18
22
|
requestAuthorization: vi.fn(async () => true),
|
|
19
23
|
}));
|
|
20
24
|
|
|
25
|
+
vi.mock('@/features/app-data/hooks/use-app-data', () => ({
|
|
26
|
+
useAppData: () => ({
|
|
27
|
+
data: { entries: mocks.retainedEntries, diagnostics: [] },
|
|
28
|
+
error: null,
|
|
29
|
+
isLoading: false,
|
|
30
|
+
refetch: mocks.refetchAppData,
|
|
31
|
+
}),
|
|
32
|
+
useDeleteRetainedAppData: () => ({
|
|
33
|
+
error: null,
|
|
34
|
+
isError: false,
|
|
35
|
+
isPending: false,
|
|
36
|
+
mutate: mocks.deleteRetained,
|
|
37
|
+
reset: mocks.deleteRetainedReset,
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
|
|
21
41
|
vi.mock('@/app/components/app-presenter-provider', () => ({
|
|
22
42
|
useAppPresenter: () => ({
|
|
23
43
|
appPackageOperationSettlementManager: { settle: vi.fn() },
|
|
@@ -227,6 +247,10 @@ describe('AppPackagesPanel', () => {
|
|
|
227
247
|
mocks.refetchPackages.mockReset();
|
|
228
248
|
mocks.refetchOperations.mockReset();
|
|
229
249
|
mocks.refetchPanels.mockReset();
|
|
250
|
+
mocks.refetchAppData.mockReset();
|
|
251
|
+
mocks.deleteRetained.mockReset();
|
|
252
|
+
mocks.deleteRetainedReset.mockReset();
|
|
253
|
+
mocks.retainedEntries = [];
|
|
230
254
|
mocks.requestAuthorization.mockReset();
|
|
231
255
|
mocks.requestAuthorization.mockResolvedValue(true);
|
|
232
256
|
});
|
|
@@ -300,8 +324,13 @@ describe('AppPackagesPanel', () => {
|
|
|
300
324
|
await user.click(screen.getByRole('button', { name: 'More app actions' }));
|
|
301
325
|
await user.click(screen.getByRole('button', { name: 'Uninstall' }));
|
|
302
326
|
expect(screen.getByRole('heading', { name: 'Uninstall this app?' })).toBeTruthy();
|
|
327
|
+
await waitFor(() => {
|
|
328
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
|
|
329
|
+
});
|
|
303
330
|
expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
|
|
304
331
|
.toBe('true');
|
|
332
|
+
expect(screen.getByText('Total app data')).toBeTruthy();
|
|
333
|
+
expect(screen.getByText('Managed instance path')).toBeTruthy();
|
|
305
334
|
|
|
306
335
|
await user.click(screen.getByRole('button', { name: 'Uninstall' }));
|
|
307
336
|
expect(mocks.lifecycleMutate).toHaveBeenCalledWith(
|
|
@@ -310,6 +339,48 @@ describe('AppPackagesPanel', () => {
|
|
|
310
339
|
);
|
|
311
340
|
});
|
|
312
341
|
|
|
342
|
+
it('shows retained data and requires a dedicated permanent-delete confirmation', async () => {
|
|
343
|
+
const user = userEvent.setup();
|
|
344
|
+
mocks.retainedEntries = [{
|
|
345
|
+
id: 'ad1.encoded',
|
|
346
|
+
appId: 'example.notes',
|
|
347
|
+
instanceId: 'default',
|
|
348
|
+
displayName: 'Notes',
|
|
349
|
+
source: 'package',
|
|
350
|
+
lifecycle: 'retained',
|
|
351
|
+
storage: createStorageFixture(),
|
|
352
|
+
usage: {
|
|
353
|
+
dataBytes: 128,
|
|
354
|
+
configBytes: 3,
|
|
355
|
+
stateBytes: 2,
|
|
356
|
+
cacheBytes: 1,
|
|
357
|
+
temporaryBytes: 0,
|
|
358
|
+
logsBytes: 4,
|
|
359
|
+
totalBytes: 138,
|
|
360
|
+
},
|
|
361
|
+
createdAt: '2026-08-14T00:00:00.000Z',
|
|
362
|
+
actions: { deleteRetainedData: true },
|
|
363
|
+
}];
|
|
364
|
+
const view = render(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
|
|
365
|
+
|
|
366
|
+
expect(screen.getByRole('heading', { name: 'Saved app data' })).toBeTruthy();
|
|
367
|
+
await user.click(screen.getByRole('button', { name: 'Delete data' }));
|
|
368
|
+
expect(screen.getByRole('heading', { name: 'Permanently delete this app data?' })).toBeTruthy();
|
|
369
|
+
|
|
370
|
+
await user.click(screen.getByRole('button', { name: 'Permanently delete data' }));
|
|
371
|
+
expect(mocks.deleteRetained).toHaveBeenCalledWith(
|
|
372
|
+
{ dataId: 'ad1.encoded', confirmAppId: 'example.notes' },
|
|
373
|
+
{ onSuccess: expect.any(Function) },
|
|
374
|
+
);
|
|
375
|
+
const onSuccess = mocks.deleteRetained.mock.calls[0]?.[1].onSuccess as () => void;
|
|
376
|
+
mocks.retainedEntries = [];
|
|
377
|
+
view.rerender(<AppPackagesPanel onOpenPanelApp={mocks.onOpen} />);
|
|
378
|
+
await act(async () => onSuccess());
|
|
379
|
+
await waitFor(() => {
|
|
380
|
+
expect(document.activeElement).toBe(screen.getByRole('heading', { name: 'Your apps' }));
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
313
384
|
it('keeps an accepted install visible in the library and marketplace while it runs', async () => {
|
|
314
385
|
const user = userEvent.setup();
|
|
315
386
|
mocks.operations = [{
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
2
|
import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
|
|
3
3
|
import {
|
|
4
4
|
AlertCircle,
|
|
5
5
|
AppWindow,
|
|
6
6
|
Bookmark,
|
|
7
7
|
CalendarDays,
|
|
8
|
-
Check,
|
|
9
8
|
CheckSquare2,
|
|
10
9
|
ChevronRight,
|
|
11
10
|
MoreHorizontal,
|
|
@@ -21,6 +20,11 @@ import {
|
|
|
21
20
|
type LucideIcon,
|
|
22
21
|
} from 'lucide-react';
|
|
23
22
|
import { AppArtwork } from '@/features/apps/components/app-artwork';
|
|
23
|
+
import {
|
|
24
|
+
AppDataRemovalChoice,
|
|
25
|
+
AppStorageUsageDetails,
|
|
26
|
+
formatBytes,
|
|
27
|
+
} from '@/features/app-data';
|
|
24
28
|
import { isAppPackageOperationActive } from '@/features/apps/hooks/use-app-packages';
|
|
25
29
|
import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
26
30
|
import { Button } from '@/shared/components/ui/button';
|
|
@@ -63,6 +67,8 @@ export function AppPackageCard({
|
|
|
63
67
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
64
68
|
const [uninstallOpen, setUninstallOpen] = useState(false);
|
|
65
69
|
const [purgeData, setPurgeData] = useState(false);
|
|
70
|
+
const skipMenuFocusRestoreRef = useRef(false);
|
|
71
|
+
const uninstallCancelRef = useRef<HTMLButtonElement>(null);
|
|
66
72
|
const panelComponents = appPackage.components.filter((component) => component.kind === 'panel');
|
|
67
73
|
const serviceCount = appPackage.components.length - panelComponents.length;
|
|
68
74
|
const rollbackVersions = appPackage.installedVersions.filter(
|
|
@@ -160,7 +166,16 @@ export function AppPackageCard({
|
|
|
160
166
|
<MoreHorizontal className="h-4 w-4" />
|
|
161
167
|
</button>
|
|
162
168
|
</PopoverTrigger>
|
|
163
|
-
<PopoverContent
|
|
169
|
+
<PopoverContent
|
|
170
|
+
align="end"
|
|
171
|
+
className="w-52 rounded-xl p-1.5"
|
|
172
|
+
onCloseAutoFocus={(event) => {
|
|
173
|
+
if (skipMenuFocusRestoreRef.current) {
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
skipMenuFocusRestoreRef.current = false;
|
|
176
|
+
}
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
164
179
|
<AppPackageMenuItem
|
|
165
180
|
icon={RefreshCw}
|
|
166
181
|
label={t('appPackagesCheckUpdate')}
|
|
@@ -185,7 +200,9 @@ export function AppPackageCard({
|
|
|
185
200
|
icon={Trash2}
|
|
186
201
|
label={t('appPackagesUninstall')}
|
|
187
202
|
onClick={() => {
|
|
203
|
+
skipMenuFocusRestoreRef.current = true;
|
|
188
204
|
setMenuOpen(false);
|
|
205
|
+
setPurgeData(false);
|
|
189
206
|
setUninstallOpen(true);
|
|
190
207
|
}}
|
|
191
208
|
/>
|
|
@@ -239,19 +256,25 @@ export function AppPackageCard({
|
|
|
239
256
|
) : null}
|
|
240
257
|
|
|
241
258
|
<Dialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
|
|
242
|
-
<DialogContent
|
|
259
|
+
<DialogContent
|
|
260
|
+
className="max-w-md [&>:last-child]:hidden"
|
|
261
|
+
onOpenAutoFocus={(event) => {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
uninstallCancelRef.current?.focus();
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
243
266
|
<DialogHeader>
|
|
244
267
|
<DialogTitle>{t('appPackagesUninstallTitle')}</DialogTitle>
|
|
245
268
|
<DialogDescription>{t('appPackagesUninstallDescription')}</DialogDescription>
|
|
246
269
|
</DialogHeader>
|
|
247
270
|
<div className="mt-4 grid gap-2">
|
|
248
|
-
<
|
|
271
|
+
<AppDataRemovalChoice
|
|
249
272
|
checked={!purgeData}
|
|
250
273
|
description={t('appPackagesKeepDataDescription')}
|
|
251
274
|
label={t('appPackagesKeepData')}
|
|
252
275
|
onClick={() => setPurgeData(false)}
|
|
253
276
|
/>
|
|
254
|
-
<
|
|
277
|
+
<AppDataRemovalChoice
|
|
255
278
|
checked={purgeData}
|
|
256
279
|
destructive
|
|
257
280
|
description={t('appPackagesDeleteDataDescription')}
|
|
@@ -259,8 +282,20 @@ export function AppPackageCard({
|
|
|
259
282
|
onClick={() => setPurgeData(true)}
|
|
260
283
|
/>
|
|
261
284
|
</div>
|
|
285
|
+
<div className="mt-3">
|
|
286
|
+
<AppStorageUsageDetails
|
|
287
|
+
storage={appPackage.storage}
|
|
288
|
+
usage={appPackage.storageUsage}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
262
291
|
<DialogFooter className="mt-5 gap-2 sm:gap-0">
|
|
263
|
-
<Button
|
|
292
|
+
<Button
|
|
293
|
+
ref={uninstallCancelRef}
|
|
294
|
+
autoFocus
|
|
295
|
+
type="button"
|
|
296
|
+
variant="outline"
|
|
297
|
+
onClick={() => setUninstallOpen(false)}
|
|
298
|
+
>
|
|
264
299
|
{t('cancel')}
|
|
265
300
|
</Button>
|
|
266
301
|
<Button
|
|
@@ -303,50 +338,6 @@ function renderPrimaryActionLabel({
|
|
|
303
338
|
return enabled ? t('appPackagesDisable') : t('appPackagesEnable');
|
|
304
339
|
}
|
|
305
340
|
|
|
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
341
|
function AppPackageMenuItem({
|
|
351
342
|
destructive = false,
|
|
352
343
|
icon: Icon,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useState, type FormEvent } from 'react';
|
|
1
|
+
import { useMemo, useRef, useState, type FormEvent } from 'react';
|
|
2
2
|
import type { AppPackageOperationView, AppPackageView } from '@nextclaw/client-sdk';
|
|
3
3
|
import { LoaderCircle, PackagePlus, RefreshCw, ShieldCheck, Store } from 'lucide-react';
|
|
4
4
|
import { AppArtwork } from '@/features/apps/components/app-artwork';
|
|
@@ -6,6 +6,7 @@ import type { PanelAppEntryView } from '@/shared/lib/api';
|
|
|
6
6
|
import { useAppPresenter } from '@/app/components/app-presenter-provider';
|
|
7
7
|
import { AppMarketplaceDialog } from '@/features/apps/components/app-marketplace-dialog';
|
|
8
8
|
import { AppPackageCard } from '@/features/apps/components/app-package-card';
|
|
9
|
+
import { AppRetainedDataSection, useAppData } from '@/features/app-data';
|
|
9
10
|
import {
|
|
10
11
|
useAppPackageMutation,
|
|
11
12
|
useAppPackageOperationSettlement,
|
|
@@ -38,12 +39,14 @@ export function AppPackagesPanel({
|
|
|
38
39
|
onOpenPanelApp: (entry: PanelAppEntryView) => void;
|
|
39
40
|
}) {
|
|
40
41
|
const appPackages = useAppPackages();
|
|
42
|
+
const appData = useAppData();
|
|
41
43
|
const appPackageOperations = useAppPackageOperations();
|
|
42
44
|
const panelApps = usePanelApps();
|
|
43
45
|
const lifecycle = useAppPackageMutation();
|
|
44
46
|
const recordOpened = useRecordPanelAppOpened();
|
|
45
47
|
const grantClient = useGrantPanelAppClient();
|
|
46
48
|
const presenter = useAppPresenter();
|
|
49
|
+
const libraryTitleRef = useRef<HTMLHeadingElement>(null);
|
|
47
50
|
const [installOpen, setInstallOpen] = useState(false);
|
|
48
51
|
const [installSource, setInstallSource] = useState('');
|
|
49
52
|
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
|
@@ -101,6 +104,7 @@ export function AppPackagesPanel({
|
|
|
101
104
|
void appPackages.refetch();
|
|
102
105
|
void appPackageOperations.refetch();
|
|
103
106
|
void panelApps.refetch();
|
|
107
|
+
void appData.refetch();
|
|
104
108
|
};
|
|
105
109
|
|
|
106
110
|
return (
|
|
@@ -144,7 +148,13 @@ export function AppPackagesPanel({
|
|
|
144
148
|
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
|
145
149
|
<div className="mx-auto w-full max-w-3xl p-3 sm:p-5">
|
|
146
150
|
<div className="mb-4 px-1">
|
|
147
|
-
<h1
|
|
151
|
+
<h1
|
|
152
|
+
ref={libraryTitleRef}
|
|
153
|
+
tabIndex={-1}
|
|
154
|
+
className="text-base font-semibold tracking-tight text-foreground"
|
|
155
|
+
>
|
|
156
|
+
{t('appPackagesLibraryTitle')}
|
|
157
|
+
</h1>
|
|
148
158
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
149
159
|
{t('appPackagesLibraryDescription')}
|
|
150
160
|
</p>
|
|
@@ -170,6 +180,13 @@ export function AppPackagesPanel({
|
|
|
170
180
|
onMutate={runMutation}
|
|
171
181
|
onOpenPanelApp={(entry) => void openPanelApp(entry)}
|
|
172
182
|
/>
|
|
183
|
+
<AppRetainedDataSection
|
|
184
|
+
diagnostics={appData.data?.diagnostics ?? []}
|
|
185
|
+
entries={appData.data?.entries ?? []}
|
|
186
|
+
error={appData.error}
|
|
187
|
+
isLoading={appData.isLoading}
|
|
188
|
+
onDeletionFocusReturn={() => libraryTitleRef.current?.focus()}
|
|
189
|
+
/>
|
|
173
190
|
</div>
|
|
174
191
|
</div>
|
|
175
192
|
|
|
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
3
3
|
import type { AppPackageOperationStatus, AppPackageOperationView } from '@nextclaw/client-sdk';
|
|
4
4
|
import { nextclawClient } from '@/shared/lib/api';
|
|
5
5
|
import type { AppPackageOperationSettlementManager } from '@/features/apps/managers/app-package-operation-settlement.manager';
|
|
6
|
+
import { APP_DATA_QUERY_KEY } from '@/features/app-data';
|
|
6
7
|
|
|
7
8
|
const APP_PACKAGES_QUERY_KEY = ['app-packages'] as const;
|
|
8
9
|
const PANEL_APPS_QUERY_KEY = ['panel-apps'] as const;
|
|
@@ -108,6 +109,7 @@ export function useAppPackageMutation() {
|
|
|
108
109
|
queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
|
|
109
110
|
queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
|
|
110
111
|
queryClient.invalidateQueries({ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY }),
|
|
112
|
+
queryClient.invalidateQueries({ queryKey: APP_DATA_QUERY_KEY }),
|
|
111
113
|
]);
|
|
112
114
|
},
|
|
113
115
|
});
|
|
@@ -8,13 +8,59 @@ const discoverServiceAppActionsMutateAsync = vi.fn(async () => ({ actions: [] })
|
|
|
8
8
|
const refetchServiceActions = vi.fn();
|
|
9
9
|
const refetchServiceActionGrants = vi.fn();
|
|
10
10
|
const refetchServiceApps = vi.fn();
|
|
11
|
+
const refetchAppData = vi.fn();
|
|
11
12
|
const restartServiceAppMutate = vi.fn();
|
|
12
13
|
const revokeServiceActionGrantMutate = vi.fn();
|
|
14
|
+
const resetDeleteServiceApp = vi.fn();
|
|
15
|
+
|
|
16
|
+
vi.mock('@/features/app-data/hooks/use-app-data', () => ({
|
|
17
|
+
useAppData: () => ({
|
|
18
|
+
data: {
|
|
19
|
+
entries: [{
|
|
20
|
+
id: 'ad1.workspace-notes',
|
|
21
|
+
appId: 'notes',
|
|
22
|
+
instanceId: 'default',
|
|
23
|
+
displayName: 'Notes',
|
|
24
|
+
source: 'workspace-service',
|
|
25
|
+
lifecycle: 'active',
|
|
26
|
+
storage: {
|
|
27
|
+
layout: 'instance-v1',
|
|
28
|
+
layoutVersion: 1,
|
|
29
|
+
instanceId: 'default',
|
|
30
|
+
instanceDirectory: '/workspace/.nextclaw/app-instances/notes/default',
|
|
31
|
+
dataDirectory: '/workspace/.nextclaw/app-instances/notes/default/data',
|
|
32
|
+
configDirectory: '/workspace/.nextclaw/app-instances/notes/default/config',
|
|
33
|
+
stateDirectory: '/workspace/.nextclaw/app-instances/notes/default/state',
|
|
34
|
+
cacheDirectory: '/workspace/.nextclaw/app-instances/notes/default/cache',
|
|
35
|
+
temporaryDirectory: '/workspace/.nextclaw/app-instances/notes/default/tmp',
|
|
36
|
+
logsDirectory: '/workspace/.nextclaw/app-instances/notes/default/logs',
|
|
37
|
+
},
|
|
38
|
+
usage: {
|
|
39
|
+
dataBytes: 12,
|
|
40
|
+
configBytes: 0,
|
|
41
|
+
stateBytes: 0,
|
|
42
|
+
cacheBytes: 0,
|
|
43
|
+
temporaryBytes: 0,
|
|
44
|
+
logsBytes: 0,
|
|
45
|
+
totalBytes: 12,
|
|
46
|
+
},
|
|
47
|
+
createdAt: '2026-08-14T00:00:00.000Z',
|
|
48
|
+
actions: { deleteRetainedData: false },
|
|
49
|
+
}],
|
|
50
|
+
diagnostics: [],
|
|
51
|
+
},
|
|
52
|
+
error: null,
|
|
53
|
+
isLoading: false,
|
|
54
|
+
refetch: refetchAppData,
|
|
55
|
+
}),
|
|
56
|
+
}));
|
|
13
57
|
|
|
14
58
|
vi.mock('@/features/service-apps/hooks/use-service-apps', () => ({
|
|
15
59
|
useDeleteServiceApp: () => ({
|
|
60
|
+
error: null,
|
|
16
61
|
isPending: false,
|
|
17
62
|
mutate: deleteServiceAppMutate,
|
|
63
|
+
reset: resetDeleteServiceApp,
|
|
18
64
|
}),
|
|
19
65
|
useDiscoverServiceAppActions: () => ({
|
|
20
66
|
isPending: false,
|
|
@@ -82,8 +128,19 @@ describe('ServiceAppsPanel', () => {
|
|
|
82
128
|
fireEvent.click(screen.getByRole('button', { name: 'More actions' }));
|
|
83
129
|
expect((screen.getByRole('button', { name: 'Disconnect runtime' }) as HTMLButtonElement).disabled).toBe(true);
|
|
84
130
|
fireEvent.click(screen.getByRole('button', { name: 'Delete service app' }));
|
|
85
|
-
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' }));
|
|
133
|
+
});
|
|
134
|
+
expect(resetDeleteServiceApp).toHaveBeenCalled();
|
|
135
|
+
expect(screen.getByText('Total app data')).toBeTruthy();
|
|
136
|
+
expect(screen.getByRole('button', { name: /Keep personal data/ }).getAttribute('aria-pressed'))
|
|
137
|
+
.toBe('true');
|
|
138
|
+
const deleteButtons = screen.getAllByRole('button', { name: 'Delete service app' });
|
|
139
|
+
fireEvent.click(deleteButtons.at(-1)!);
|
|
86
140
|
|
|
87
|
-
expect(deleteServiceAppMutate).toHaveBeenCalledWith(
|
|
141
|
+
expect(deleteServiceAppMutate).toHaveBeenCalledWith(
|
|
142
|
+
{ appId: 'notes', purgeData: false },
|
|
143
|
+
{ onSuccess: expect.any(Function) },
|
|
144
|
+
);
|
|
88
145
|
});
|
|
89
146
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { AppDataEntry } from "@nextclaw/client-sdk";
|
|
3
|
+
import { LoaderCircle } from "lucide-react";
|
|
4
|
+
import { AppDataRemovalChoice, AppStorageUsageDetails } from "@/features/app-data";
|
|
5
|
+
import { Button } from "@/shared/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/shared/components/ui/dialog";
|
|
14
|
+
import { t } from "@/shared/lib/i18n";
|
|
15
|
+
|
|
16
|
+
export function ServiceAppDeleteDialog({
|
|
17
|
+
appId,
|
|
18
|
+
appTitle,
|
|
19
|
+
dataEntry,
|
|
20
|
+
dataLoading,
|
|
21
|
+
deleteError,
|
|
22
|
+
deletePending,
|
|
23
|
+
open,
|
|
24
|
+
purgeData,
|
|
25
|
+
onDelete,
|
|
26
|
+
onOpenChange,
|
|
27
|
+
onPurgeDataChange,
|
|
28
|
+
}: {
|
|
29
|
+
appId: string;
|
|
30
|
+
appTitle: string;
|
|
31
|
+
dataEntry?: AppDataEntry;
|
|
32
|
+
dataLoading: boolean;
|
|
33
|
+
deleteError: unknown;
|
|
34
|
+
deletePending: boolean;
|
|
35
|
+
open: boolean;
|
|
36
|
+
purgeData: boolean;
|
|
37
|
+
onDelete: (appId: string, purgeData: boolean, onSuccess: () => void) => void;
|
|
38
|
+
onOpenChange: (open: boolean) => void;
|
|
39
|
+
onPurgeDataChange: (purgeData: boolean) => void;
|
|
40
|
+
}) {
|
|
41
|
+
const errorRef = useRef<HTMLParagraphElement>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (deleteError) errorRef.current?.focus();
|
|
45
|
+
}, [deleteError]);
|
|
46
|
+
|
|
47
|
+
const close = () => onOpenChange(false);
|
|
48
|
+
return (
|
|
49
|
+
<Dialog
|
|
50
|
+
open={open}
|
|
51
|
+
onOpenChange={(nextOpen) => !deletePending && onOpenChange(nextOpen)}
|
|
52
|
+
>
|
|
53
|
+
<DialogContent
|
|
54
|
+
className="max-w-md [&>:last-child]:hidden"
|
|
55
|
+
onEscapeKeyDown={(event) => deletePending && event.preventDefault()}
|
|
56
|
+
onInteractOutside={(event) => deletePending && event.preventDefault()}
|
|
57
|
+
>
|
|
58
|
+
<DialogHeader>
|
|
59
|
+
<DialogTitle>{t("serviceAppsDeleteConfirmTitle")}</DialogTitle>
|
|
60
|
+
<DialogDescription>
|
|
61
|
+
{t("serviceAppsDeleteConfirmDescription")} {appTitle}
|
|
62
|
+
</DialogDescription>
|
|
63
|
+
</DialogHeader>
|
|
64
|
+
<div className="mt-4 grid gap-2">
|
|
65
|
+
<AppDataRemovalChoice
|
|
66
|
+
checked={!purgeData}
|
|
67
|
+
description={t("serviceAppsKeepDataDescription")}
|
|
68
|
+
label={t("appPackagesKeepData")}
|
|
69
|
+
onClick={() => onPurgeDataChange(false)}
|
|
70
|
+
/>
|
|
71
|
+
<AppDataRemovalChoice
|
|
72
|
+
checked={purgeData}
|
|
73
|
+
destructive
|
|
74
|
+
disabled={!dataEntry || dataLoading}
|
|
75
|
+
description={t("serviceAppsDeleteDataDescription")}
|
|
76
|
+
label={t("appPackagesDeleteData")}
|
|
77
|
+
onClick={() => onPurgeDataChange(true)}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
{dataEntry ? (
|
|
81
|
+
<div className="mt-3">
|
|
82
|
+
<AppStorageUsageDetails storage={dataEntry.storage} usage={dataEntry.usage} />
|
|
83
|
+
</div>
|
|
84
|
+
) : (
|
|
85
|
+
<p role="status" className="mt-3 text-xs text-muted-foreground">
|
|
86
|
+
{dataLoading ? t("appPackagesLoading") : t("serviceAppsDataUnavailable")}
|
|
87
|
+
</p>
|
|
88
|
+
)}
|
|
89
|
+
{deleteError ? (
|
|
90
|
+
<p
|
|
91
|
+
ref={errorRef}
|
|
92
|
+
role="alert"
|
|
93
|
+
tabIndex={-1}
|
|
94
|
+
className="mt-3 text-xs text-destructive"
|
|
95
|
+
>
|
|
96
|
+
{deleteError instanceof Error ? deleteError.message : t("appPackagesActionFailed")}
|
|
97
|
+
</p>
|
|
98
|
+
) : null}
|
|
99
|
+
<DialogFooter className="mt-5 gap-2 sm:gap-0">
|
|
100
|
+
<Button
|
|
101
|
+
autoFocus
|
|
102
|
+
type="button"
|
|
103
|
+
variant="outline"
|
|
104
|
+
disabled={deletePending}
|
|
105
|
+
onClick={close}
|
|
106
|
+
>
|
|
107
|
+
{t("cancel")}
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
type="button"
|
|
111
|
+
variant={purgeData ? "destructive" : "default"}
|
|
112
|
+
disabled={deletePending || (purgeData && !dataEntry)}
|
|
113
|
+
onClick={() => onDelete(appId, purgeData, close)}
|
|
114
|
+
>
|
|
115
|
+
{deletePending ? <LoaderCircle className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
|
116
|
+
{purgeData ? t("appDataDeletePermanently") : t("serviceAppsDelete")}
|
|
117
|
+
</Button>
|
|
118
|
+
</DialogFooter>
|
|
119
|
+
</DialogContent>
|
|
120
|
+
</Dialog>
|
|
121
|
+
);
|
|
122
|
+
}
|