Skip to content

Commit 2b1b513

Browse files
committed
Merge branch 'feature/up' into develop
2 parents 9d1bea1 + 77bf5be commit 2b1b513

7 files changed

Lines changed: 259 additions & 37 deletions

File tree

e2e/data-transfer.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,29 @@ test.describe('data transfer', () => {
1717
await expect
1818
.poll(async () => (await getMockCalls(page)).some((call) => call.cmd === 'export_data'))
1919
.toBe(true)
20+
21+
await expect(page.getByText('The export is complete.')).toBeVisible()
22+
await expect
23+
.poll(async () => {
24+
const calls = await getMockCalls(page)
25+
const revealCall = calls.find((call) => call.cmd === 'show_item_in_folder')
26+
return revealCall ? firstInvokeArg(revealCall) : null
27+
})
28+
.toBe('/Users/e2e/exports/switchhosts_20260509_121436.789.json')
2029
})
2130

2231
test('imports backup data from a file', async ({ page }) => {
2332
await clearMockCalls(page)
33+
await page.evaluate(() => window.__SWITCHHOSTS_E2E__.delayNextImport(600))
2434

2535
await page.getByLabel('Settings').click()
2636
await page.getByRole('menuitem', { name: /^Import$/ }).click()
2737

38+
await expect(page.getByText('Loading...').first()).toBeVisible()
2839
await expect(page.locator('[data-id="imported-local"]')).toContainText('Imported Backup')
2940
await expect(page.locator('[data-id="imported-folder"]')).toContainText('Imported Folder')
3041
await expect(page.locator('[data-id="imported-group"]')).toContainText('Imported Group')
42+
await expect(page.getByText('The import is complete.')).toBeVisible()
3143
await expect
3244
.poll(async () => {
3345
const state = await getMockState(page)
@@ -47,6 +59,19 @@ test.describe('data transfer', () => {
4759
expect(calls.some((call) => call.cmd === 'import_data')).toBe(true)
4860
})
4961

62+
test('shows an error notification when file import fails', async ({ page }) => {
63+
await clearMockCalls(page)
64+
await page.evaluate(() => window.__SWITCHHOSTS_E2E__.failNextImport('mock_import_error'))
65+
66+
await page.getByLabel('Settings').click()
67+
await page.getByRole('menuitem', { name: /^Import$/ }).click()
68+
69+
await expect(page.getByText('Import failed! [mock_import_error]')).toBeVisible()
70+
71+
const calls = await getMockCalls(page)
72+
expect(calls.some((call) => call.cmd === 'import_data')).toBe(true)
73+
})
74+
5075
test('imports backup data from a URL', async ({ page }) => {
5176
await clearMockCalls(page)
5277

@@ -62,6 +87,7 @@ test.describe('data transfer', () => {
6287
await expect(page.locator('[data-id="imported-url"]')).toContainText('Imported From URL')
6388
await page.locator('[data-id="imported-url"]').click()
6489
await expect(page.getByText(importUrl)).toBeVisible()
90+
await expect(page.getByText('The import is complete.')).toBeVisible()
6591
await expect
6692
.poll(async () => {
6793
const state = await getMockState(page)
@@ -77,4 +103,24 @@ test.describe('data transfer', () => {
77103
expect(importCall).toBeDefined()
78104
expect(firstInvokeArg(importCall!)).toBe(importUrl)
79105
})
106+
107+
test('shows an error notification when URL import fails', async ({ page }) => {
108+
await clearMockCalls(page)
109+
await page.evaluate(() => window.__SWITCHHOSTS_E2E__.failNextImportFromUrl('mock_url_error'))
110+
111+
const importUrl = 'https://example.test/swh_data.json'
112+
await page.getByLabel('Settings').click()
113+
await page.getByRole('menuitem', { name: 'Import from URL' }).click()
114+
115+
const dialog = page.getByRole('dialog')
116+
await dialog.locator('input').fill(importUrl)
117+
await dialog.getByRole('button', { name: 'OK' }).click()
118+
119+
await expect(page.getByText('Import failed! [mock_url_error]')).toBeVisible()
120+
121+
const calls = await getMockCalls(page)
122+
const importCall = calls.find((call) => call.cmd === 'import_data_from_url')
123+
expect(importCall).toBeDefined()
124+
expect(firstInvokeArg(importCall!)).toBe(importUrl)
125+
})
80126
})

e2e/support/tauri-mock.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
const normalizeLineEndings = (value) => value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
66

7+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
8+
79
const flatten = (items) =>
810
items.flatMap((item) => [item, ...flatten(Array.isArray(item.children) ? item.children : [])])
911

@@ -253,6 +255,18 @@
253255
...result,
254256
}
255257
},
258+
delayNextImport: (ms = 300) => {
259+
state.nextImportDelayMs = ms
260+
},
261+
failNextImport: (result = 'mock_import_error') => {
262+
state.nextImportResult = result
263+
},
264+
delayNextImportFromUrl: (ms = 300) => {
265+
state.nextImportFromUrlDelayMs = ms
266+
},
267+
failNextImportFromUrl: (result = 'mock_import_url_error') => {
268+
state.nextImportFromUrlResult = result
269+
},
256270
}
257271

258272
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = {
@@ -393,8 +407,18 @@
393407
}
394408
return refreshRemote(params[0])
395409
case 'export_data':
396-
return '/Users/e2e/exports/swh_data.json'
410+
return '/Users/e2e/exports/switchhosts_20260509_121436.789.json'
397411
case 'import_data':
412+
if (state.nextImportDelayMs) {
413+
const ms = state.nextImportDelayMs
414+
state.nextImportDelayMs = 0
415+
await delay(ms)
416+
}
417+
if (Object.prototype.hasOwnProperty.call(state, 'nextImportResult')) {
418+
const result = state.nextImportResult
419+
delete state.nextImportResult
420+
return result
421+
}
398422
state.list = [
399423
{
400424
id: 'imported-local',
@@ -430,6 +454,16 @@
430454
state.trashcan = []
431455
return true
432456
case 'import_data_from_url':
457+
if (state.nextImportFromUrlDelayMs) {
458+
const ms = state.nextImportFromUrlDelayMs
459+
state.nextImportFromUrlDelayMs = 0
460+
await delay(ms)
461+
}
462+
if (Object.prototype.hasOwnProperty.call(state, 'nextImportFromUrlResult')) {
463+
const result = state.nextImportFromUrlResult
464+
delete state.nextImportFromUrlResult
465+
return result
466+
}
433467
state.list = [
434468
{
435469
id: 'imported-url',
@@ -457,6 +491,7 @@
457491
case 'hide_main_window':
458492
case 'quit_app':
459493
case 'open_url':
494+
case 'show_item_in_folder':
460495
case 'popup_menu':
461496
return true
462497
case 'check_update':

e2e/support/test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ declare global {
5050
clearCalls: () => void
5151
failNextApply: (result?: { code?: string; message?: string }) => void
5252
failNextRefresh: (result?: { code?: string; message?: string }) => void
53+
delayNextImport: (ms?: number) => void
54+
failNextImport: (result?: string | false | null) => void
55+
delayNextImportFromUrl: (ms?: number) => void
56+
failNextImportFromUrl: (result?: string | false | null) => void
5357
}
5458
}
5559
}

src-tauri/src/commands.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,14 @@ pub async fn show_item_in_folder(args: Args) -> Value {
10401040
// come back as Ok(Value::String("error_code")) the renderer can
10411041
// display.
10421042

1043+
fn export_file_name_for(now: chrono::DateTime<chrono::Local>) -> String {
1044+
format!("switchhosts_{}.json", now.format("%Y%m%d_%H%M%S%.3f"))
1045+
}
1046+
1047+
fn default_export_file_name() -> String {
1048+
export_file_name_for(chrono::Local::now())
1049+
}
1050+
10431051
#[tauri::command]
10441052
pub async fn export_data<R: Runtime>(
10451053
app: AppHandle<R>,
@@ -1050,7 +1058,7 @@ pub async fn export_data<R: Runtime>(
10501058
.dialog()
10511059
.file()
10521060
.add_filter("JSON", &["json"])
1053-
.set_file_name("swh_data.json")
1061+
.set_file_name(&default_export_file_name())
10541062
.blocking_save_file();
10551063

10561064
let Some(dest) = picked else {
@@ -1070,6 +1078,28 @@ pub async fn export_data<R: Runtime>(
10701078
Ok(Value::String(dest_path.display().to_string()))
10711079
}
10721080

1081+
#[cfg(test)]
1082+
mod export_file_name_tests {
1083+
use chrono::{TimeZone, Timelike};
1084+
1085+
use super::export_file_name_for;
1086+
1087+
#[test]
1088+
fn includes_millisecond_timestamp() {
1089+
let now = chrono::Local
1090+
.with_ymd_and_hms(2026, 5, 9, 12, 14, 36)
1091+
.single()
1092+
.expect("test timestamp should be representable")
1093+
.with_nanosecond(789_000_000)
1094+
.expect("test nanosecond should be valid");
1095+
1096+
assert_eq!(
1097+
export_file_name_for(now),
1098+
"switchhosts_20260509_121436.789.json"
1099+
);
1100+
}
1101+
}
1102+
10731103
#[tauri::command]
10741104
pub async fn import_data<R: Runtime>(
10751105
app: AppHandle<R>,

src/renderer/components/TopBar/ConfigMenu.tsx

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ import events from '@common/events'
88
import { ActionIcon, Menu, type MenuProps, ScrollArea, Tooltip } from '@mantine/core'
99
import ImportFromUrl from '@renderer/components/TopBar/ImportFromUrl'
1010
import { actions, agent } from '@renderer/core/agent'
11-
import { getErrorMessage, showErrorNotification, showSuccessNotification } from '@renderer/core/notify'
11+
import {
12+
getErrorMessage,
13+
hideAppNotification,
14+
showErrorNotification,
15+
showLoadingNotification,
16+
showSuccessNotification,
17+
updateErrorNotification,
18+
updateSuccessNotification,
19+
} from '@renderer/core/notify'
1220
import useHostsData from '@renderer/models/useHostsData'
1321
import useI18n from '@renderer/models/useI18n'
1422
import {
@@ -118,13 +126,32 @@ const ConfigMenu = (props: IProps) => {
118126
<Menu.Item
119127
leftSection={<IconUpload size={iconSize} stroke={strokeWidth} />}
120128
onClick={async () => {
121-
const r = await actions.exportData()
122-
if (r === null) {
123-
return
124-
} else if (r === false) {
125-
console.error(lang.import_fail)
126-
} else {
127-
console.log(lang.export_done)
129+
try {
130+
const r = await actions.exportData()
131+
if (r === null) {
132+
return
133+
} else if (r === false) {
134+
showErrorNotification({
135+
title: lang.export,
136+
message: lang.fail,
137+
})
138+
} else if (typeof r === 'string') {
139+
showSuccessNotification({
140+
title: lang.export,
141+
message: lang.export_done,
142+
})
143+
144+
try {
145+
await actions.showItemInFolder(r)
146+
} catch (error) {
147+
console.error(error)
148+
}
149+
}
150+
} catch (error) {
151+
showErrorNotification({
152+
title: lang.export,
153+
message: getErrorMessage(error, lang.fail),
154+
})
128155
}
129156
}}
130157
>
@@ -133,20 +160,39 @@ const ConfigMenu = (props: IProps) => {
133160
<Menu.Item
134161
leftSection={<IconDownload size={iconSize} stroke={strokeWidth} />}
135162
onClick={async () => {
136-
const r = await actions.importData()
137-
if (r === null) {
138-
return
139-
} else if (r === true) {
140-
console.log(lang.import_done)
141-
await loadHostsData()
142-
setCurrentHosts(null)
143-
} else {
144-
let description = lang.import_fail
145-
if (typeof r === 'string') {
146-
description += ` [${r}]`
147-
}
163+
const notificationId = showLoadingNotification({
164+
title: lang.import,
165+
message: lang.loading,
166+
})
148167

149-
console.error(description)
168+
try {
169+
const r = await actions.importData()
170+
if (r === null) {
171+
hideAppNotification(notificationId)
172+
return
173+
} else if (r === true) {
174+
await loadHostsData()
175+
setCurrentHosts(null)
176+
updateSuccessNotification(notificationId, {
177+
title: lang.import,
178+
message: lang.import_done,
179+
})
180+
} else {
181+
let description = lang.import_fail
182+
if (typeof r === 'string') {
183+
description += ` [${r}]`
184+
}
185+
186+
updateErrorNotification(notificationId, {
187+
title: lang.import,
188+
message: description,
189+
})
190+
}
191+
} catch (error) {
192+
updateErrorNotification(notificationId, {
193+
title: lang.import,
194+
message: getErrorMessage(error, lang.import_fail),
195+
})
150196
}
151197
}}
152198
>

src/renderer/components/TopBar/ImportFromUrl.tsx

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55

66
import { Button, Group, Modal, TextInput } from '@mantine/core'
77
import { actions } from '@renderer/core/agent'
8+
import {
9+
getErrorMessage,
10+
showLoadingNotification,
11+
updateErrorNotification,
12+
updateSuccessNotification,
13+
} from '@renderer/core/notify'
814
import useHostsData from '@renderer/models/useHostsData'
915
import useI18n from '@renderer/models/useI18n'
1016
import React, { useState } from 'react'
@@ -29,24 +35,39 @@ const ImportFromUrl = (props: Props) => {
2935

3036
const onOk = async () => {
3137
setIsShow(false)
32-
console.log(`url: ${url}`)
3338

3439
if (url) {
35-
const r = await actions.importDataFromUrl(url)
36-
console.log(r)
40+
const notificationId = showLoadingNotification({
41+
title: lang.import_from_url,
42+
message: lang.loading,
43+
})
3744

38-
if (r === true) {
39-
// import success
40-
console.log(lang.import_done)
41-
await loadHostsData()
42-
setCurrentHosts(null)
43-
} else {
44-
let description = lang.import_fail
45-
if (typeof r === 'string') {
46-
description += ` [${r}]`
47-
}
45+
try {
46+
const r = await actions.importDataFromUrl(url)
47+
48+
if (r === true) {
49+
await loadHostsData()
50+
setCurrentHosts(null)
51+
updateSuccessNotification(notificationId, {
52+
title: lang.import_from_url,
53+
message: lang.import_done,
54+
})
55+
} else {
56+
let description = lang.import_fail
57+
if (typeof r === 'string') {
58+
description += ` [${r}]`
59+
}
4860

49-
console.error(description)
61+
updateErrorNotification(notificationId, {
62+
title: lang.import_from_url,
63+
message: description,
64+
})
65+
}
66+
} catch (error) {
67+
updateErrorNotification(notificationId, {
68+
title: lang.import_from_url,
69+
message: getErrorMessage(error, lang.import_fail),
70+
})
5071
}
5172
}
5273
setUrl('')

0 commit comments

Comments
 (0)