Skip to content

Commit 47dbda4

Browse files
JayantDevkarclaude
andcommitted
fix: sync tab UX — live reload, flash messages, duplicate prevention
- All tab components reload data on tab switch via active prop + $effect - DevicesTab shows flash messages on add/remove, auto-uppercases device ID - DeviceCard uses amber dot for disconnected, adds copy device ID button - ProjectsTab uses live Syncthing device count instead of stale team config - SetupTab conditionally renders version to avoid blank space - Duplicate device add returns HTTP 409 with SyncthingClient pre-check - Improved confirmation popover styling for device removal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 83418da commit 47dbda4

File tree

8 files changed

+142
-37
lines changed

8 files changed

+142
-37
lines changed

api/routers/sync_status.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ async def sync_add_device(req: AddDeviceRequest) -> Any:
202202
return await run_sync(proxy.add_device, req.device_id, req.name)
203203
except SyncthingNotRunning:
204204
raise HTTPException(status_code=503, detail="Syncthing is not running")
205+
except ValueError as e:
206+
raise HTTPException(status_code=409, detail=str(e))
205207

206208

207209
@router.delete("/devices/{device_id}")

cli/karma/syncthing.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ def get_connections(self) -> dict:
8888
def add_device(self, device_id: str, name: str) -> None:
8989
"""Pair with a remote device."""
9090
config = self._get_config()
91+
existing_ids = {d["deviceID"] for d in config.get("devices", [])}
92+
if device_id in existing_ids:
93+
raise ValueError(f"Device {device_id} already configured")
9194
config["devices"].append({
9295
"deviceID": device_id,
9396
"name": name,

frontend/src/lib/components/sync/ActivityTab.svelte

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import { API_BASE } from '$lib/config';
66
import { getProjectNameFromEncoded, formatBytes, formatBytesRate, formatRelativeTime } from '$lib/utils';
77
8+
let { active = false }: { active?: boolean } = $props();
9+
810
const MAX_HISTORY = 30;
911
const POLL_INTERVAL = 3000;
1012
@@ -268,9 +270,15 @@
268270
}
269271
}
270272
273+
// Reload when tab becomes active
274+
$effect(() => {
275+
if (active) {
276+
loadLookupMaps();
277+
fetchActivity();
278+
}
279+
});
280+
271281
onMount(() => {
272-
loadLookupMaps();
273-
fetchActivity();
274282
pollTimer = setInterval(fetchActivity, POLL_INTERVAL);
275283
});
276284

frontend/src/lib/components/sync/DeviceCard.svelte

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
11
<script lang="ts">
2-
import { Monitor, ChevronDown, ChevronRight, ArrowUp, ArrowDown, Lock } from 'lucide-svelte';
2+
import {
3+
Monitor,
4+
ChevronDown,
5+
ChevronRight,
6+
ArrowUp,
7+
ArrowDown,
8+
Lock,
9+
Copy,
10+
CheckCircle
11+
} from 'lucide-svelte';
312
import type { SyncDevice } from '$lib/api-types';
413
import { formatBytes } from '$lib/utils';
514
615
let { device }: { device: SyncDevice } = $props();
716
817
let expanded = $state(false);
18+
let copiedId = $state(false);
919
1020
let statusDotClass = $derived(
11-
device.connected || device.is_self ? 'bg-[var(--success)]' : 'bg-[var(--text-muted)]'
21+
device.connected || device.is_self ? 'bg-[var(--success)]' : 'bg-[var(--warning)]'
1222
);
1323
1424
let statusText = $derived(
1525
device.is_self ? 'Online' : device.connected ? 'Connected' : 'Disconnected'
1626
);
1727
1828
let truncatedId = $derived(
19-
device.device_id.length > 32 ? device.device_id.slice(0, 32) + '' : device.device_id
29+
device.device_id.length > 32 ? device.device_id.slice(0, 32) + '\u2026' : device.device_id
2030
);
31+
32+
function copyDeviceId() {
33+
navigator.clipboard
34+
.writeText(device.device_id)
35+
.then(() => {
36+
copiedId = true;
37+
setTimeout(() => (copiedId = false), 2000);
38+
})
39+
.catch(() => {});
40+
}
2141
</script>
2242

23-
<div class="rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-subtle)] overflow-hidden">
43+
<div
44+
class="rounded-[var(--radius-lg)] border border-[var(--border)] bg-[var(--bg-subtle)] overflow-hidden"
45+
>
2446
<!-- Header (always visible) -->
2547
<button
2648
onclick={() => (expanded = !expanded)}
@@ -79,14 +101,18 @@
79101
<div class="px-4 pb-4 pt-2 border-t border-[var(--border)] space-y-4">
80102
<!-- Connection section -->
81103
<div>
82-
<h4 class="text-[11px] font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-2">
104+
<h4
105+
class="text-[11px] font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-2"
106+
>
83107
Connection
84108
</h4>
85109
<div class="space-y-1.5">
86110
{#if device.address}
87111
<div class="flex items-center justify-between gap-4">
88112
<span class="text-xs text-[var(--text-secondary)]">Address</span>
89-
<span class="text-xs font-mono text-[var(--text-primary)] truncate max-w-[60%] text-right">
113+
<span
114+
class="text-xs font-mono text-[var(--text-primary)] truncate max-w-[60%] text-right"
115+
>
90116
{device.address}
91117
</span>
92118
</div>
@@ -108,16 +134,36 @@
108134
{/if}
109135
<div class="flex items-center justify-between gap-4">
110136
<span class="text-xs text-[var(--text-secondary)]">Device ID</span>
111-
<code class="text-[11px] font-mono text-[var(--text-muted)] truncate max-w-[60%] text-right">
112-
{truncatedId}
113-
</code>
137+
<div class="flex items-center gap-1.5">
138+
<code
139+
class="text-[11px] font-mono text-[var(--text-muted)] truncate max-w-[55%] text-right"
140+
>
141+
{truncatedId}
142+
</code>
143+
<button
144+
onclick={(e) => {
145+
e.stopPropagation();
146+
copyDeviceId();
147+
}}
148+
aria-label="Copy device ID"
149+
class="shrink-0 p-1 rounded text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
150+
>
151+
{#if copiedId}
152+
<CheckCircle size={12} class="text-[var(--success)]" />
153+
{:else}
154+
<Copy size={12} />
155+
{/if}
156+
</button>
157+
</div>
114158
</div>
115159
</div>
116160
</div>
117161

118162
<!-- Transfer section -->
119163
<div>
120-
<h4 class="text-[11px] font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-2">
164+
<h4
165+
class="text-[11px] font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-2"
166+
>
121167
Transfer
122168
</h4>
123169
<div class="space-y-1.5">

frontend/src/lib/components/sync/DevicesTab.svelte

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
<script lang="ts">
2-
import { onMount } from 'svelte';
3-
import { Monitor, XCircle, Plus, Loader2, Trash2 } from 'lucide-svelte';
2+
import { Monitor, XCircle, Plus, Loader2, Trash2, CheckCircle2 } from 'lucide-svelte';
43
import type { SyncDetect, SyncDevice } from '$lib/api-types';
54
import { API_BASE } from '$lib/config';
65
import DeviceCard from './DeviceCard.svelte';
76
8-
let { detect }: { detect: SyncDetect | null } = $props();
7+
let { detect, active = false }: { detect: SyncDetect | null; active?: boolean } = $props();
98
109
let devices = $state<SyncDevice[]>([]);
1110
let loading = $state(true);
@@ -19,6 +18,16 @@
1918
let removingDeviceId = $state<string | null>(null);
2019
let removeConfirmId = $state<string | null>(null);
2120
21+
// Flash message
22+
let flashMessage = $state<string | null>(null);
23+
let flashTimeout: ReturnType<typeof setTimeout> | null = null;
24+
25+
function showFlash(msg: string) {
26+
flashMessage = msg;
27+
if (flashTimeout) clearTimeout(flashTimeout);
28+
flashTimeout = setTimeout(() => (flashMessage = null), 3000);
29+
}
30+
2231
async function loadDevices() {
2332
loading = true;
2433
error = null;
@@ -56,14 +65,16 @@
5665
method: 'POST',
5766
headers: { 'Content-Type': 'application/json' },
5867
body: JSON.stringify({
59-
device_id: newDeviceId.trim(),
68+
device_id: newDeviceId.trim().toUpperCase(),
6069
name: newDeviceName.trim() || newDeviceId.trim()
6170
})
6271
});
6372
if (res.ok) {
73+
const addedName = newDeviceName.trim() || 'Device';
6474
newDeviceId = '';
6575
newDeviceName = '';
6676
await loadDevices();
77+
showFlash(`${addedName} paired successfully`);
6778
} else {
6879
const body = await res.json().catch(() => ({}));
6980
pairError = body?.detail ?? 'Failed to pair device.';
@@ -76,13 +87,15 @@
7687
}
7788
7889
async function removeDevice(deviceId: string) {
90+
const deviceName = devices.find((d) => d.device_id === deviceId)?.name ?? 'Device';
7991
removingDeviceId = deviceId;
8092
try {
8193
const res = await fetch(`${API_BASE}/sync/devices/${encodeURIComponent(deviceId)}`, {
8294
method: 'DELETE'
8395
});
8496
if (res.ok) {
8597
await loadDevices();
98+
showFlash(`${deviceName} removed`);
8699
}
87100
} catch {
88101
// ignore
@@ -92,12 +105,25 @@
92105
}
93106
}
94107
95-
onMount(() => {
96-
loadDevices();
108+
// Reload when tab becomes active
109+
$effect(() => {
110+
if (active) {
111+
loadDevices();
112+
}
97113
});
98114
</script>
99115

100116
<div class="p-6 space-y-4">
117+
<!-- Flash message -->
118+
{#if flashMessage}
119+
<div
120+
class="flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] bg-[var(--success)]/10 border border-[var(--success)]/20 text-xs font-medium text-[var(--success)]"
121+
>
122+
<CheckCircle2 size={14} class="shrink-0" />
123+
{flashMessage}
124+
</div>
125+
{/if}
126+
101127
{#if loading}
102128
<!-- Skeleton -->
103129
<div class="space-y-3">
@@ -139,18 +165,20 @@
139165
{#if !device.is_self}
140166
<!-- Remove button overlay -->
141167
{#if removeConfirmId === device.device_id}
142-
<div class="absolute top-2 right-2 flex items-center gap-1.5 bg-[var(--bg-subtle)] rounded-md p-1 border border-[var(--border)]">
143-
<span class="text-xs text-[var(--text-muted)] px-1">Remove?</span>
168+
<div
169+
class="absolute top-1.5 right-1.5 flex items-center gap-1.5 bg-[var(--bg-base)] rounded-lg px-2.5 py-1.5 border border-[var(--border)] shadow-md z-10"
170+
>
171+
<span class="text-xs text-[var(--text-secondary)]">Remove?</span>
144172
<button
145173
onclick={() => removeDevice(device.device_id)}
146174
disabled={removingDeviceId === device.device_id}
147-
class="px-2 py-0.5 text-xs font-medium rounded bg-[var(--error-subtle)] text-[var(--error)] border border-[var(--error)]/20 hover:bg-[var(--error)]/20 transition-colors disabled:opacity-50"
175+
class="px-2.5 py-1 text-xs font-medium rounded-md bg-[var(--error)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
148176
>
149177
{removingDeviceId === device.device_id ? '...' : 'Yes'}
150178
</button>
151179
<button
152180
onclick={() => (removeConfirmId = null)}
153-
class="px-2 py-0.5 text-xs font-medium rounded border border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
181+
class="px-2.5 py-1 text-xs font-medium rounded-md border border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
154182
>
155183
No
156184
</button>
@@ -186,8 +214,11 @@
186214
type="text"
187215
bind:value={newDeviceId}
188216
placeholder="XXXXXXX-XXXXXXX-..."
189-
class="w-full px-3 py-2 text-xs font-mono rounded-[var(--radius)] border border-[var(--border)] bg-[var(--bg-base)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]/40 focus:border-[var(--accent)] transition-colors"
217+
class="w-full px-3 py-2 text-sm font-mono rounded-[var(--radius)] border border-[var(--border)] bg-[var(--bg-base)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]/40 focus:border-[var(--accent)] transition-colors uppercase"
190218
/>
219+
<p class="text-[11px] text-[var(--text-muted)]">
220+
Find this in Syncthing &rarr; Actions &rarr; Show ID
221+
</p>
191222
</div>
192223
<div class="space-y-1.5">
193224
<label for="new-device-name" class="block text-xs font-medium text-[var(--text-secondary)]">

frontend/src/lib/components/sync/ProjectsTab.svelte

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<script lang="ts">
2-
import { onMount } from 'svelte';
32
import { FolderGit2, RefreshCw } from 'lucide-svelte';
43
import type { SyncProject } from '$lib/api-types';
54
import { API_BASE } from '$lib/config';
@@ -23,6 +22,8 @@
2322
members: string[];
2423
}
2524
25+
let { active = false }: { active?: boolean } = $props();
26+
2627
let projects = $state<SyncProject[]>([]);
2728
let loading = $state(true);
2829
let error = $state<string | null>(null);
@@ -32,9 +33,10 @@
3233
loading = true;
3334
error = null;
3435
try {
35-
const [projectsRes, teamsRes] = await Promise.all([
36+
const [projectsRes, teamsRes, devicesRes] = await Promise.all([
3637
fetch(`${API_BASE}/projects`),
37-
fetch(`${API_BASE}/sync/teams`).catch(() => null)
38+
fetch(`${API_BASE}/sync/teams`).catch(() => null),
39+
fetch(`${API_BASE}/sync/devices`).catch(() => null)
3840
]);
3941
4042
const apiProjects: ApiProject[] = projectsRes.ok ? await projectsRes.json() : [];
@@ -51,6 +53,15 @@
5153
}
5254
}
5355
56+
// Count actual remote Syncthing devices (excluding self)
57+
let remoteDeviceCount = 0;
58+
if (devicesRes?.ok) {
59+
const devData = await devicesRes.json();
60+
remoteDeviceCount = (devData.devices ?? []).filter(
61+
(d: { is_self?: boolean }) => !d.is_self
62+
).length;
63+
}
64+
5465
projects = apiProjects.map((p) => {
5566
const isSynced = syncedSet.has(p.encoded_name);
5667
return {
@@ -60,7 +71,7 @@
6071
synced: isSynced,
6172
status: isSynced ? ('synced' as const) : ('not-syncing' as const),
6273
last_sync_at: null,
63-
machine_count: teamCountMap.get(p.encoded_name) ?? 0,
74+
machine_count: isSynced ? remoteDeviceCount : 0,
6475
pending_count: 0
6576
};
6677
});
@@ -106,8 +117,10 @@
106117
107118
let unsyncedCount = $derived(projects.filter((p) => !p.synced).length);
108119
109-
onMount(() => {
110-
loadProjects();
120+
$effect(() => {
121+
if (active) {
122+
loadProjects();
123+
}
111124
});
112125
</script>
113126

frontend/src/lib/components/sync/SetupTab.svelte

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
88
let {
99
detect = $bindable(),
10-
status = $bindable()
10+
status = $bindable(),
11+
active = false
1112
}: {
1213
detect: SyncDetect | null;
1314
status: SyncStatusResponse | null;
15+
active?: boolean;
1416
} = $props();
1517
1618
// --- State 1: Not Detected ---
@@ -135,7 +137,7 @@
135137
}
136138
137139
$effect(() => {
138-
if (status?.configured) {
140+
if (status?.configured && active) {
139141
loadOverview();
140142
}
141143
});
@@ -269,7 +271,7 @@
269271
></span>
270272
<div>
271273
<span class="text-sm font-semibold text-[var(--text-primary)]">
272-
Syncthing {detect.version ?? ''} running
274+
Syncthing{#if detect.version} v{detect.version}{/if} running
273275
</span>
274276
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
275277
One more step — name this machine to start syncing.
@@ -366,7 +368,7 @@
366368
Sync configured
367369
</span>
368370
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
369-
Syncthing {detect?.version ?? ''} is running. Manage devices in the Devices tab.
371+
Syncthing{#if detect?.version} v{detect.version}{/if} is running. Manage devices in the Devices tab.
370372
</p>
371373
</div>
372374
</div>

0 commit comments

Comments
 (0)