Skip to content

Commit c090948

Browse files
committed
feat(server-clone): Phase 4 — Validation + edge cases
Autonomous decisions: - PrefixCache reads alias from cached_definition before registry lookup so clone suffix aliases resolve at connect time - Uninstall-source UX: three-action modal (cancel / source-only / uninstall all) via UninstallSourceWithClonesDialog - list_clone_dependents filters list_for_space by cloned_from — no new repo method needed Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent a45d34c commit c090948

10 files changed

Lines changed: 740 additions & 38 deletions

File tree

apps/desktop/src-tauri/src/commands/server_clone.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,21 @@ pub async fn suggest_clone_suffix(
7373
.await
7474
.map_err(|e| e.to_string())
7575
}
76+
77+
/// List installed servers in a space that were cloned from the given source.
78+
#[tauri::command]
79+
pub async fn list_clone_dependents(
80+
app_service: State<'_, Arc<RwLock<Option<ServerAppService>>>>,
81+
space_id: String,
82+
source_server_id: String,
83+
) -> Result<Vec<InstalledServer>, String> {
84+
let service_lock = app_service.read().await;
85+
let service = service_lock
86+
.as_ref()
87+
.ok_or("ServerAppService not initialized")?;
88+
89+
service
90+
.list_clone_dependents(&space_id, &source_server_id)
91+
.await
92+
.map_err(|e| e.to_string())
93+
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,7 @@ pub fn run() {
873873
commands::clone_server,
874874
commands::is_clone_id_available,
875875
commands::suggest_clone_suffix,
876+
commands::list_clone_dependents,
876877
// FeatureSet commands
877878
commands::list_feature_sets,
878879
commands::list_feature_sets_by_space,

apps/desktop/src/features/servers/ServersPage.tsx

Lines changed: 109 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from 'lucide-react';
2323
import { ServerActionMenu } from './ServerActionMenu';
2424
import { CloneAccountModal } from './CloneAccountModal';
25+
import { UninstallSourceWithClonesDialog } from './UninstallSourceWithClonesDialog';
2526
import type { ServerViewModel, ServerDefinition, InstalledServerState, InputDefinition } from '../../types/registry';
2627
import type { ServerFeature } from '@/lib/api/serverFeatures';
2728
import { listServerFeaturesByServer } from '@/lib/api/serverFeatures';
@@ -38,6 +39,7 @@ import { ConfigEditorModal } from '@/components/ConfigEditorModal';
3839
import { ServerDefinitionModal } from '@/components/ServerDefinitionModal';
3940
import { SourceBadge } from '@/components/SourceBadge';
4041
import type { ClonedInstalledServer } from '@/lib/api/serverClone';
42+
import { listCloneDependents } from '@/lib/api/serverClone';
4143

4244
/** Server view model extended with optional clone lineage from the backend. */
4345
type ServerViewModelWithClone = ServerViewModel & { cloned_from?: string };
@@ -220,6 +222,12 @@ export function ServersPage() {
220222

221223
// Clone account wizard state
222224
const [cloneModalServer, setCloneModalServer] = useState<ServerViewModelWithClone | null>(null);
225+
226+
// Uninstall source-with-clones confirmation
227+
const [uninstallClonesDialog, setUninstallClonesDialog] = useState<{
228+
server: ServerViewModelWithClone;
229+
dependents: ClonedInstalledServer[];
230+
} | null>(null);
223231

224232
// Config editor state
225233
const [editConfigSpace, setEditConfigSpace] = useState<{ id: string; name: string } | null>(null);
@@ -795,31 +803,103 @@ export function ServersPage() {
795803
}
796804
};
797805

798-
const handleUninstall = async (server: ServerViewModel) => {
806+
const performUninstall = async (serverIds: string[]) => {
807+
const { uninstallServer } = await import('@/lib/api/registry');
808+
const { disconnectServer } = await import('@/lib/api/gateway');
809+
810+
if (gatewayRunning && viewSpace) {
811+
for (const serverId of serverIds) {
812+
const target = installedServers.find((entry) => entry.id === serverId);
813+
if (!target?.enabled) {
814+
continue;
815+
}
816+
817+
try {
818+
await disconnectServer(serverId, viewSpace.id);
819+
} catch (error) {
820+
console.warn(`[ServersPage] Failed to disconnect server from gateway:`, error);
821+
}
822+
}
823+
}
824+
825+
for (const serverId of serverIds) {
826+
await uninstallServer(serverId, viewSpace?.id ?? '');
827+
}
828+
829+
await loadData();
830+
};
831+
832+
const handleUninstall = async (server: ServerViewModelWithClone) => {
833+
if (!viewSpace) {
834+
return;
835+
}
836+
837+
if (!server.cloned_from) {
838+
try {
839+
const dependents = await listCloneDependents(viewSpace.id, server.id);
840+
if (dependents.length > 0) {
841+
setUninstallClonesDialog({ server, dependents });
842+
return;
843+
}
844+
} catch (error) {
845+
showToast(String(error), 'error');
846+
return;
847+
}
848+
}
849+
799850
const { getUninstallLabel } = await import('@/components/SourceBadge');
800851
const actionLabel = getUninstallLabel(server.installation_source);
801852

802853
setActionLoading(`uninstall-${server.id}`);
803854
try {
804-
const { uninstallServer } = await import('@/lib/api/registry');
805-
const { disconnectServer } = await import('@/lib/api/gateway');
806-
807-
if (gatewayRunning && server.enabled && viewSpace) {
808-
try {
809-
await disconnectServer(server.id, viewSpace.id);
810-
} catch (e) {
811-
console.warn(`[ServersPage] Failed to disconnect server from gateway:`, e);
812-
}
813-
}
814-
815-
// ServerAppService handles source-aware cleanup automatically:
816-
// - UserConfig: removes from JSON file + DB
817-
// - Registry/ManualEntry: just removes from DB
818-
await uninstallServer(server.id, viewSpace?.id ?? '');
819-
await loadData();
855+
await performUninstall([server.id]);
820856
showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success');
821-
} catch (e) {
822-
showToast(String(e), 'error');
857+
} catch (error) {
858+
showToast(String(error), 'error');
859+
} finally {
860+
setActionLoading(null);
861+
}
862+
};
863+
864+
const handleUninstallSourceOnly = async () => {
865+
if (!uninstallClonesDialog) {
866+
return;
867+
}
868+
869+
const { server } = uninstallClonesDialog;
870+
const { getUninstallLabel } = await import('@/components/SourceBadge');
871+
const actionLabel = getUninstallLabel(server.installation_source);
872+
873+
setUninstallClonesDialog(null);
874+
setActionLoading(`uninstall-${server.id}`);
875+
try {
876+
await performUninstall([server.id]);
877+
showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success');
878+
} catch (error) {
879+
showToast(String(error), 'error');
880+
} finally {
881+
setActionLoading(null);
882+
}
883+
};
884+
885+
const handleUninstallAllWithClones = async () => {
886+
if (!uninstallClonesDialog) {
887+
return;
888+
}
889+
890+
const { server, dependents } = uninstallClonesDialog;
891+
const serverIds = [...dependents.map((dependent) => dependent.server_id), server.id];
892+
893+
setUninstallClonesDialog(null);
894+
setActionLoading(`uninstall-${server.id}`);
895+
try {
896+
await performUninstall(serverIds);
897+
showToast(
898+
`${server.name} and ${dependents.length} clone${dependents.length === 1 ? '' : 's'} uninstalled`,
899+
'success'
900+
);
901+
} catch (error) {
902+
showToast(String(error), 'error');
823903
} finally {
824904
setActionLoading(null);
825905
}
@@ -930,6 +1010,16 @@ export function ServersPage() {
9301010
return (
9311011
<div className="space-y-6" data-testid="servers-page">
9321012
{gatewayControl.ConfirmDialogElement}
1013+
{uninstallClonesDialog && (
1014+
<UninstallSourceWithClonesDialog
1015+
open
1016+
sourceName={uninstallClonesDialog.server.name}
1017+
dependents={uninstallClonesDialog.dependents}
1018+
onCancel={() => setUninstallClonesDialog(null)}
1019+
onUninstallSourceOnly={handleUninstallSourceOnly}
1020+
onUninstallAll={handleUninstallAllWithClones}
1021+
/>
1022+
)}
9331023
{/* Header */}
9341024
<div className="flex items-center justify-between">
9351025
<div>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { AlertCircle } from 'lucide-react';
2+
3+
export interface CloneDependentSummary {
4+
server_id: string;
5+
server_name?: string | null;
6+
}
7+
8+
interface UninstallSourceWithClonesDialogProps {
9+
open: boolean;
10+
sourceName: string;
11+
dependents: CloneDependentSummary[];
12+
onCancel: () => void;
13+
onUninstallSourceOnly: () => void;
14+
onUninstallAll: () => void;
15+
}
16+
17+
/**
18+
* Warn when uninstalling a source server that still has account clones in the same space.
19+
*/
20+
export function UninstallSourceWithClonesDialog({
21+
open,
22+
sourceName,
23+
dependents,
24+
onCancel,
25+
onUninstallSourceOnly,
26+
onUninstallAll,
27+
}: UninstallSourceWithClonesDialogProps) {
28+
if (!open) {
29+
return null;
30+
}
31+
32+
const dependentLabels = dependents.map(
33+
(dependent) => dependent.server_name ?? dependent.server_id
34+
);
35+
const dependentList = dependentLabels.join(', ');
36+
const totalCount = dependents.length + 1;
37+
38+
return (
39+
<div
40+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
41+
onClick={onCancel}
42+
data-testid="uninstall-clones-dialog-overlay"
43+
>
44+
<div
45+
className="mx-4 w-full max-w-md rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--card))] p-6 shadow-xl animate-in fade-in zoom-in-95 duration-200"
46+
onClick={(event) => event.stopPropagation()}
47+
data-testid="uninstall-clones-dialog"
48+
>
49+
<div className="mb-4 flex items-start gap-3">
50+
<div className="flex-shrink-0 rounded-full bg-amber-500/10 p-2">
51+
<AlertCircle className="h-5 w-5 text-amber-500" />
52+
</div>
53+
<div>
54+
<h3 className="text-base font-semibold">Uninstall server with account clones?</h3>
55+
<p className="mt-2 text-sm text-[rgb(var(--muted))]">
56+
<span className="font-medium text-[rgb(var(--foreground))]">{sourceName}</span> has{' '}
57+
{dependents.length} account clone{dependents.length === 1 ? '' : 's'} in this space:{' '}
58+
<span className="font-medium text-[rgb(var(--foreground))]">{dependentList}</span>.
59+
</p>
60+
<p className="mt-2 text-sm text-[rgb(var(--muted))]">
61+
Uninstalling the source leaves clones installed and working. You can also remove
62+
everything at once.
63+
</p>
64+
</div>
65+
</div>
66+
<div className="flex flex-wrap justify-end gap-3">
67+
<button
68+
onClick={onCancel}
69+
className="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-active))] px-4 py-2 text-sm font-medium text-[rgb(var(--foreground))] transition-colors hover:bg-[rgb(var(--surface-hover))]"
70+
data-testid="uninstall-clones-cancel"
71+
>
72+
Cancel
73+
</button>
74+
<button
75+
onClick={onUninstallSourceOnly}
76+
className="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface-active))] px-4 py-2 text-sm font-medium text-[rgb(var(--foreground))] transition-colors hover:bg-[rgb(var(--surface-hover))]"
77+
data-testid="uninstall-clones-source-only"
78+
>
79+
Uninstall source only
80+
</button>
81+
<button
82+
onClick={onUninstallAll}
83+
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700"
84+
data-testid="uninstall-clones-all"
85+
>
86+
Uninstall all ({totalCount})
87+
</button>
88+
</div>
89+
</div>
90+
</div>
91+
);
92+
}

apps/desktop/src/lib/api/serverClone.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ export async function suggestCloneSuffix(
5858
});
5959
}
6060

61+
/**
62+
* List account clones that were created from the given source server in a space.
63+
*/
64+
export async function listCloneDependents(
65+
spaceId: string,
66+
sourceServerId: string
67+
): Promise<ClonedInstalledServer[]> {
68+
return invoke<ClonedInstalledServer[]>('list_clone_dependents', {
69+
spaceId,
70+
sourceServerId,
71+
});
72+
}
73+
6174
/**
6275
* Normalize a server ID the same way the backend does (lowercase, strip underscores/spaces).
6376
*/

0 commit comments

Comments
 (0)