Skip to content

Commit 0057c3a

Browse files
pmaxhoganclaude
andcommitted
fix(ui): give the onboarding wizard a real Drive folder browser
The first-run setup wizard's "Choose Drive folder" button called pick_drive_folder(account, null), which the backend resolves to the My Drive root and returns with current_folder_id="root" and an always-empty current_folder_path. The wizard rendered its confirmation via v-if="setup.driveFolderPath" (always empty), so the click produced no visible feedback and the button looked broken; there was also no folder navigation at all - it silently targeted the entire My Drive root with no way to pick a subfolder. The working breadcrumb folder browser lived only in AddSourceWizard, so the two flows had drifted and only the setup-wizard copy was broken. Extract that browser into a shared DriveFolderPicker (breadcrumb navigation + folder list + a live "Backing up to: My Drive / ..." destination line) and use it in BOTH the setup wizard and the Settings "Add source" wizard. Onboarding now has real folder navigation and visible feedback, and the duplication that caused the drift is gone. - new ui/src/components/DriveFolderPicker.vue (v-model:folderId/folderPath, emits raw errors so each parent maps them in its own style) - SetupWizard.vue + AddSourceWizard.vue both consume it; ~60 lines of duplicated crumb logic removed from AddSourceWizard - drivePicker.* i18n keys (rootName "My Drive", destinationLabel "Backing up to", empty-folder hint) - tests updated; full ui suite (204) + vue-tsc + eslint + prettier green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MZQh3ZfwtZsM6c5qnTuWZP
1 parent 8108f50 commit 0057c3a

6 files changed

Lines changed: 193 additions & 125 deletions

File tree

ui/src/__tests__/settings-components.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,9 +533,9 @@ describe("AddSourceWizard", () => {
533533
await flushPromises();
534534
};
535535

536-
// -> Drive step: root listing loaded, path empty.
536+
// -> Drive step: root listing loaded, destination shows My Drive root.
537537
await clickNext();
538-
const driveLabel = i18n.global.t("settings.addSource.step.driveFolder");
538+
const driveLabel = i18n.global.t("drivePicker.destinationLabel");
539539
expect(wrapper.text()).toContain(`${driveLabel}:`);
540540

541541
// Click the "Docs" folder to descend; the rendered path must now be "Docs",

ui/src/__tests__/setup-wizard.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,13 @@ describe("SetupWizard walks all five steps (DESIGN s8.5)", () => {
378378
await flushPromises();
379379
expect(setup.localPath).toBe("/home/user/Docs");
380380

381-
const chooseDrive = wrapper
382-
.findAll("button")
383-
.find((b) => b.text() === i18n.global.t("settings.addSource.chooseDriveButton"));
384-
await chooseDrive!.trigger("click");
381+
// Drive destination: the shared DriveFolderPicker auto-loads My Drive root on
382+
// mount and selects it (no button click needed - that missing feedback was the
383+
// bug). The fake backend returns drive-folder-1 as the current folder id.
385384
await flushPromises();
386385
expect(setup.driveFolderId).toBe("drive-folder-1");
386+
// The picker surfaces the chosen destination instead of a dead button.
387+
expect(wrapper.find('[data-testid="drive-folder-picker"]').exists()).toBe(true);
387388

388389
await wrapper.get("footer button:last-child").trigger("click");
389390
expect(setup.step).toBe("encryption");

ui/src/components/AddSourceWizard.vue

Lines changed: 17 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
import { computed, ref } from "vue";
33
import { useI18n } from "vue-i18n";
44
5+
import DriveFolderPicker from "./DriveFolderPicker.vue";
56
import RecoveryPhraseReveal from "./RecoveryPhraseReveal.vue";
67
import * as ipc from "../ipc/commands";
78
import { toErrorCode } from "../ipc/errors";
89
import { useAccountsStore } from "../stores/accounts";
910
import { useSourcesStore } from "../stores/sources";
10-
import type { DriveFolderEntry, ExclusionPreview, SourceDto } from "../ipc/types";
11+
import type { ExclusionPreview, SourceDto } from "../ipc/types";
1112
1213
// Add-source wizard (SPEC s11.2; DESIGN s8.5 step 3 / s8.2 add-source wizard).
1314
// Five steps: pick a LOCAL folder (tauri-plugin-dialog, dialog-derived path
@@ -75,15 +76,8 @@ const createdSource = ref<SourceDto | null>(null);
7576
// calls revealRecoveryPhrase (the backend reveal the ack gate requires).
7677
const pendingRecoveryAck = ref(false);
7778
78-
// Drive picker state: a breadcrumb stack of the folders descended into, so "up"
79-
// can re-fetch the parent. The first entry (null id) is the Drive root.
80-
interface Crumb {
81-
id: string | null;
82-
path: string;
83-
}
84-
const crumbs = ref<Crumb[]>([]);
85-
const driveFolders = ref<DriveFolderEntry[]>([]);
86-
const drivePickerLoading = ref(false);
79+
// Drive destination (id + human path) is owned by the shared DriveFolderPicker
80+
// via v-model; this component only stages the chosen values for add_source.
8781
8882
const preview = ref<ExclusionPreview | null>(null);
8983
const previewLoading = ref(false);
@@ -151,8 +145,6 @@ function reset(): void {
151145
recoveryPhrase.value = [];
152146
createdSource.value = null;
153147
pendingRecoveryAck.value = false;
154-
crumbs.value = [];
155-
driveFolders.value = [];
156148
preview.value = null;
157149
errorMessage.value = null;
158150
revealErrorCode.value = null;
@@ -176,47 +168,9 @@ async function chooseLocalFolder(): Promise<void> {
176168
}
177169
}
178170
179-
async function loadDriveFolder(crumb: Crumb): Promise<void> {
180-
if (accountId.value === null) return;
181-
drivePickerLoading.value = true;
182-
errorMessage.value = null;
183-
try {
184-
const listing = await ipc.pickDriveFolder(accountId.value, crumb.id);
185-
driveFolders.value = listing.folders;
186-
driveFolderId.value = listing.currentFolderId;
187-
// R4-P2-2: the backend cannot derive the full breadcrumb (it lists one
188-
// folder's children, not the ancestor chain), so it returns an empty
189-
// `currentFolderPath`. The wizard maintains the breadcrumb itself in the
190-
// `crumbs` stack (descend appends `parent/name`), so persist THAT path -
191-
// using the empty backend value here was what left `drive_folder_path` blank
192-
// in SQLite. Fall back to the backend value only if the crumb has no path
193-
// (root), keeping "My Drive" root as empty.
194-
driveFolderPath.value = crumb.path || listing.currentFolderPath;
195-
} catch (e) {
196-
errorMessage.value = String(e);
197-
} finally {
198-
drivePickerLoading.value = false;
199-
}
200-
}
201-
202-
async function openDriveRoot(): Promise<void> {
203-
crumbs.value = [{ id: null, path: "" }];
204-
await loadDriveFolder(crumbs.value[0]);
205-
}
206-
207-
async function descendInto(folder: DriveFolderEntry): Promise<void> {
208-
const parentPath = driveFolderPath.value;
209-
const crumb: Crumb = {
210-
id: folder.id,
211-
path: parentPath ? `${parentPath}/${folder.name}` : folder.name,
212-
};
213-
crumbs.value.push(crumb);
214-
await loadDriveFolder(crumb);
215-
}
216-
217-
async function goToCrumb(index: number): Promise<void> {
218-
crumbs.value = crumbs.value.slice(0, index + 1);
219-
await loadDriveFolder(crumbs.value[index]);
171+
/** Surface a Drive-picker failure on the wizard's shared error line. */
172+
function onDrivePickerError(e: unknown): void {
173+
errorMessage.value = String(e);
220174
}
221175
222176
async function loadPreview(): Promise<void> {
@@ -242,10 +196,9 @@ async function loadPreview(): Promise<void> {
242196
async function next(): Promise<void> {
243197
if (stepIndex.value >= STEPS.length - 1) return;
244198
stepIndex.value += 1;
245-
// Lazily load each step's data as it becomes active.
246-
if (step.value === "driveFolder" && crumbs.value.length === 0) {
247-
await openDriveRoot();
248-
} else if (step.value === "exclusions") {
199+
// Lazily load each step's data as it becomes active. The Drive step
200+
// self-loads its root listing when the shared DriveFolderPicker mounts.
201+
if (step.value === "exclusions") {
249202
await loadPreview();
250203
}
251204
}
@@ -417,39 +370,14 @@ defineExpose({ start });
417370
</p>
418371
</div>
419372

420-
<!-- Step 2: Drive folder picker -->
373+
<!-- Step 2: Drive folder picker (shared with the first-run setup wizard) -->
421374
<div v-else-if="step === 'driveFolder'" class="space-y-3">
422-
<nav class="flex flex-wrap items-center gap-1 text-xs">
423-
<button
424-
v-for="(crumb, i) in crumbs"
425-
:key="i"
426-
type="button"
427-
class="rounded px-1 py-0.5 text-zinc-600 transition-colors hover:text-teal-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500 dark:text-zinc-400 dark:hover:text-teal-300"
428-
@click="goToCrumb(i)"
429-
>
430-
{{ i === 0 ? t("settings.addSource.step.driveFolder") : crumb.path.split("/").pop() }}
431-
</button>
432-
</nav>
433-
<p v-if="drivePickerLoading" class="text-sm text-zinc-500">
434-
{{ t("common.loading") }}
435-
</p>
436-
<ul
437-
v-else
438-
class="max-h-56 divide-y divide-zinc-200 overflow-auto rounded-md border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-700"
439-
>
440-
<li v-for="folder in driveFolders" :key="folder.id">
441-
<button
442-
type="button"
443-
class="w-full px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 dark:hover:bg-zinc-800"
444-
@click="descendInto(folder)"
445-
>
446-
{{ folder.name }}
447-
</button>
448-
</li>
449-
</ul>
450-
<p class="text-sm text-zinc-600 dark:text-zinc-400">
451-
{{ t("settings.addSource.step.driveFolder") }}: {{ driveFolderPath }}
452-
</p>
375+
<DriveFolderPicker
376+
v-model:folder-id="driveFolderId"
377+
v-model:folder-path="driveFolderPath"
378+
:account-id="accountId"
379+
@error="onDrivePickerError"
380+
/>
453381
</div>
454382

455383
<!-- Step 3: exclusions preview -->
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<script setup lang="ts">
2+
import { ref, watch } from "vue";
3+
import { useI18n } from "vue-i18n";
4+
5+
import * as ipc from "../ipc/commands";
6+
import type { DriveFolderEntry } from "../ipc/types";
7+
8+
// Shared Drive destination picker (SPEC s11.2; DESIGN s8.5 step 3). Used by BOTH
9+
// the first-run setup wizard AND the Settings "Add source" wizard, so the two
10+
// flows can never drift again. They DID drift: the setup wizard had a degenerate
11+
// single-shot button that silently targeted My Drive root, showed no confirmation
12+
// (it bound feedback to the always-empty backend `currentFolderPath`), and gave
13+
// no way to pick a subfolder - so it looked broken even though it "worked". This
14+
// breadcrumb browser (previously only in AddSourceWizard) is now the single
15+
// implementation both flows mount.
16+
//
17+
// Behavior: list a Drive folder's child folders, descend by clicking a folder,
18+
// climb via the breadcrumb. The CURRENTLY-shown folder is the selected
19+
// destination (published via the folderId + folderPath v-models). The Drive root
20+
// ("My Drive") is itself a valid destination, so landing on the picker
21+
// immediately selects it AND shows it - the feedback whose absence made the old
22+
// button look dead.
23+
//
24+
// Breadcrumb path: the backend cannot derive the ancestor chain (it lists one
25+
// folder's children, not the path TO it) and returns an EMPTY currentFolderPath,
26+
// so this component maintains the human path itself in `crumbs` (parent/name) and
27+
// publishes THAT as folderPath - keeping backup_sources.drive_folder_path real.
28+
//
29+
// Errors are emitted raw so each parent maps them in its own style: the setup
30+
// wizard maps to a stable SPEC s24 code (errors.${code}.long); AddSourceWizard
31+
// shows String(e). i18n: every visible string is a seeded key.
32+
33+
const { t } = useI18n();
34+
35+
const props = defineProps<{ accountId: string | null }>();
36+
const emit = defineEmits<{ (e: "error", err: unknown): void }>();
37+
38+
const folderId = defineModel<string | null>("folderId", { default: null });
39+
const folderPath = defineModel<string>("folderPath", { default: "" });
40+
41+
// Breadcrumb stack of the folders descended into; the first entry (null id) is
42+
// My Drive root. "up" re-fetches an ancestor; descend appends a child.
43+
interface Crumb {
44+
id: string | null;
45+
path: string;
46+
}
47+
const crumbs = ref<Crumb[]>([]);
48+
const folders = ref<DriveFolderEntry[]>([]);
49+
const loading = ref(false);
50+
51+
async function loadFolder(crumb: Crumb): Promise<void> {
52+
if (props.accountId === null) return;
53+
loading.value = true;
54+
try {
55+
const listing = await ipc.pickDriveFolder(props.accountId, crumb.id);
56+
folders.value = listing.folders;
57+
// B1: the current folder is itself the selectable destination (the backend
58+
// echoes a concrete id - "root" for My Drive - never null).
59+
folderId.value = listing.currentFolderId;
60+
// R4-P2-2: persist the client-maintained breadcrumb path (the backend
61+
// returns ""). Fall back to the backend value only at the root (empty crumb).
62+
folderPath.value = crumb.path || listing.currentFolderPath;
63+
} catch (e) {
64+
emit("error", e);
65+
} finally {
66+
loading.value = false;
67+
}
68+
}
69+
70+
async function openRoot(): Promise<void> {
71+
crumbs.value = [{ id: null, path: "" }];
72+
await loadFolder(crumbs.value[0]);
73+
}
74+
75+
async function descendInto(folder: DriveFolderEntry): Promise<void> {
76+
const parentPath = folderPath.value;
77+
const crumb: Crumb = {
78+
id: folder.id,
79+
path: parentPath ? `${parentPath}/${folder.name}` : folder.name,
80+
};
81+
crumbs.value.push(crumb);
82+
await loadFolder(crumb);
83+
}
84+
85+
async function goToCrumb(index: number): Promise<void> {
86+
crumbs.value = crumbs.value.slice(0, index + 1);
87+
await loadFolder(crumbs.value[index]);
88+
}
89+
90+
// Load My Drive root as soon as an account is available (and on mount). Landing
91+
// on the picker selects the root, so the destination is never silently unset and
92+
// the user always sees where they will back up.
93+
watch(
94+
() => props.accountId,
95+
(id) => {
96+
if (id) void openRoot();
97+
},
98+
{ immediate: true }
99+
);
100+
</script>
101+
102+
<template>
103+
<div class="space-y-3" data-testid="drive-folder-picker">
104+
<nav v-if="accountId" class="flex flex-wrap items-center gap-1 text-xs">
105+
<template v-for="(crumb, i) in crumbs" :key="i">
106+
<span v-if="i > 0" class="text-zinc-400 dark:text-zinc-600" aria-hidden="true">/</span>
107+
<button
108+
type="button"
109+
class="rounded px-1 py-0.5 text-zinc-600 transition-colors hover:text-teal-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500 dark:text-zinc-400 dark:hover:text-teal-300"
110+
@click="goToCrumb(i)"
111+
>
112+
{{ i === 0 ? t("drivePicker.rootName") : crumb.path.split("/").pop() }}
113+
</button>
114+
</template>
115+
</nav>
116+
117+
<p v-if="loading" class="text-sm text-zinc-500">
118+
{{ t("common.loading") }}
119+
</p>
120+
<template v-else-if="accountId">
121+
<ul
122+
v-if="folders.length > 0"
123+
class="max-h-56 divide-y divide-zinc-200 overflow-auto rounded-md border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-700"
124+
>
125+
<li v-for="folder in folders" :key="folder.id">
126+
<button
127+
type="button"
128+
class="w-full px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 dark:hover:bg-zinc-800"
129+
@click="descendInto(folder)"
130+
>
131+
{{ folder.name }}
132+
</button>
133+
</li>
134+
</ul>
135+
<p
136+
v-else
137+
class="rounded-md border border-dashed border-zinc-300 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-700"
138+
>
139+
{{ t("drivePicker.empty") }}
140+
</p>
141+
</template>
142+
143+
<p class="text-sm text-zinc-700 dark:text-zinc-200" data-testid="drive-destination">
144+
{{ t("drivePicker.destinationLabel") }}: {{ folderPath || t("drivePicker.rootName") }}
145+
</p>
146+
</div>
147+
</template>

ui/src/locales/en-US.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@
3737
"no": "No",
3838
"none": "None"
3939
},
40+
"drivePicker": {
41+
"rootName": "My Drive",
42+
"destinationLabel": "Backing up to",
43+
"empty": "No subfolders here - this folder is the destination."
44+
},
4045
"wizard": {
4146
"title": "Set up Driven",
4247
"stepLabel": "Step {current} of {total}",

0 commit comments

Comments
 (0)