diff --git a/docs/project_context.md b/docs/project_context.md index 90ac46f..0610493 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -1,4 +1,12 @@ # 项目上下文记录 +## 2026-05-22:统一日志框与桌面端下载路径反馈 + +- 数据处理页菜单从“数据上传”改为“数据处理”,路由标题同步改为“数据处理”。 +- 上传/处理日志、历史详情日志和脚本执行日志统一使用分级日志渲染:默认/INFO 跟随主题文字色,SUCCESS 绿色,WARNING 黄色,ERROR 红色;日志框背景和滚动条颜色跟随主题,支持横纵向滚动。 +- 数据处理页日志高度改为响应式上限,避免长任务日志把外层页面撑出纵向滚动条。 +- 脚本编辑页的手动运行日志移到编辑器下方,编辑器与日志框共享页面高度;脚本运行状态保存在前端全局状态中,切换页面回来可继续轮询或查看上次运行结果,运行结束不自动隐藏日志。 +- 桌面端下载完成弹窗会显示保存路径,并提供“打开所在文件夹”;新增 Tauri 命令 `open_path_in_file_manager` 通过系统文件管理器打开下载目录。 + ## 2026-05-21:桌面端 sidecar 进程改为跨平台管理 - Tauri 桌面端启动/关闭后端 sidecar 不再调用 Windows `netstat`、`tasklist`、`taskkill` 等控制台命令,避免启动和退出时闪过 DOS 窗口。 diff --git a/frontend/src/AppShell.vue b/frontend/src/AppShell.vue index 1fe2f49..4afca6f 100644 --- a/frontend/src/AppShell.vue +++ b/frontend/src/AppShell.vue @@ -178,7 +178,7 @@ type MenuKey = (typeof menuKeys)[number]; let brandClickResetTimer: number | undefined; const menuOptions: MenuOption[] = [ - { label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) }, + { label: '数据处理', key: 'workflow', icon: renderIcon(CloudUploadOutline) }, { label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) }, { label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) }, { label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) }, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 132caa0..e44d303 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -11,6 +11,11 @@ let cachedTauriInvoke: TauriInvoke | null = null; type TauriInvoke = (command: string, args?: Record) => Promise; +export interface DownloadResult { + saved: boolean; + path?: string; +} + interface TauriDownloadHeader { name: string; value: string; @@ -149,7 +154,7 @@ export function upload( }); } -export async function download(url: string, body: unknown, filename: string): Promise { +export async function download(url: string, body: unknown, filename: string): Promise { const headers = new Headers({ 'Content-Type': 'application/json' }); const token = getToken(); if (token) { @@ -180,7 +185,7 @@ export async function download(url: string, body: unknown, filename: string): Pr return await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename); } -export async function downloadGet(url: string, fallbackFilename: string): Promise { +export async function downloadGet(url: string, fallbackFilename: string): Promise { const headers = new Headers(); const token = getToken(); if (token) { @@ -247,7 +252,7 @@ function parseApiError(data: ApiError, fallback: string): { message: string; det }; } -async function saveBlobResponse(response: Response, filename: string): Promise { +async function saveBlobResponse(response: Response, filename: string): Promise { const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); @@ -255,19 +260,25 @@ async function saveBlobResponse(response: Response, filename: string): Promise { +async function downloadWithTauri(request: TauriDownloadRequest): Promise { try { const invoke = await getTauriInvoke(); const result = await invoke('download_to_file', { request }); - return result.saved; + return { saved: result.saved, path: result.path }; } catch (error) { throw parseTauriDownloadError(error); } } +export async function openPathInFileManager(path: string): Promise { + if (!isTauriRuntime()) return; + const invoke = await getTauriInvoke(); + await invoke('open_path_in_file_manager', { path }); +} + async function getTauriInvoke(): Promise { if (!cachedTauriInvoke) { const api = await import('@tauri-apps/api/core'); diff --git a/frontend/src/components/DatabasePanel.vue b/frontend/src/components/DatabasePanel.vue index 3bc1ae6..f639878 100644 --- a/frontend/src/components/DatabasePanel.vue +++ b/frontend/src/components/DatabasePanel.vue @@ -487,14 +487,14 @@ async function downloadSelectedCsv() { const filename = `${selectedCsvTable.value}.csv`; downloadingFormat.value = 'csv'; try { - const completed = await download( + const result = await download( '/api/download', { table_name: selectedCsvTable.value, format: 'csv' }, filename ); - if (completed) { + if (result.saved) { csvDialogVisible.value = false; - showDownloadCompleteDialog(dialog, filename); + showDownloadCompleteDialog(dialog, filename, result.path); } } catch (error) { message.error(error instanceof Error ? error.message : '导出失败'); @@ -510,14 +510,14 @@ async function downloadSelectedXlsx() { downloadingFormat.value = 'xlsx'; const filename = selectedXlsxTables.value.length === 1 ? `${selectedXlsxTables.value[0]}.xlsx` : 'tables.xlsx'; try { - const completed = await download( + const result = await download( '/api/download', { table_names: selectedXlsxTables.value, format: 'xlsx' }, filename ); - if (completed) { + if (result.saved) { xlsxDialogVisible.value = false; - showDownloadCompleteDialog(dialog, filename); + showDownloadCompleteDialog(dialog, filename, result.path); } } catch (error) { message.error(error instanceof Error ? error.message : '导出失败'); diff --git a/frontend/src/components/FileWorkflow.vue b/frontend/src/components/FileWorkflow.vue index 567e676..d741cb1 100644 --- a/frontend/src/components/FileWorkflow.vue +++ b/frontend/src/components/FileWorkflow.vue @@ -141,7 +141,12 @@ {{ taskStatus.error }}
-
{{ logText }}
+
{{ line.text }}
@@ -189,6 +194,7 @@ import { import { ApiRequestError, apiGet, apiPost, upload } from '../api/client'; import type { ActiveTask, ApiErrorDetail, LicenseStatus, TaskStatus } from '../types'; +import { toColoredLogLines } from '../composables/logLines'; type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error'; @@ -248,6 +254,7 @@ const stageLabels: Record = { }; const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待任务开始'); +const logLines = computed(() => toColoredLogLines(logText.value, '等待任务开始')); const processVisible = computed(() => Boolean(taskStatus.value || activeTask.value?.has_active)); const taskInProgress = computed(() => { if (activeTask.value?.has_active) { diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index 3140c87..1456c69 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -101,10 +101,9 @@ import { CopyOutline, DownloadOutline, FileTrayOutline, RefreshOutline, TrashOut import { apiGet, apiPost, download as downloadFile } from '../api/client'; import type { CacheSize, HistoryDetail, HistoryRecord } from '../types'; import { showDownloadCompleteDialog } from '../composables/downloadFeedback'; +import { toColoredLogLines } from '../composables/logLines'; import { resetPageHeader, setPageHeader } from '../composables/pageHeader'; -type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error'; - const message = useMessage(); const dialog = useDialog(); const records = ref([]); @@ -120,13 +119,7 @@ let recordSizeToken = 0; const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`); const hasRecords = computed(() => records.value.length > 0); const detailLogText = computed(() => detail.value?.logs?.join('\n') || ''); -const detailLogLines = computed(() => { - const lines = detailLogText.value ? detailLogText.value.split(/\r?\n/) : ['暂无日志']; - return lines.map(line => ({ - text: line, - level: resolveLogLevel(line) - })); -}); +const detailLogLines = computed(() => toColoredLogLines(detailLogText.value)); onMounted(() => { setPageHeader({ @@ -186,9 +179,9 @@ async function downloadHistory(record: HistoryRecord) { downloadingRecordId.value = record.id; try { const filename = `${record.id}.zip`; - const completed = await downloadFile('/api/history/download', { record_id: record.id }, filename); - if (completed) { - showDownloadCompleteDialog(dialog, filename); + const result = await downloadFile('/api/history/download', { record_id: record.id }, filename); + if (result.saved) { + showDownloadCompleteDialog(dialog, filename, result.path); } } catch (error) { message.error(error instanceof Error ? error.message : '下载历史数据失败'); @@ -256,14 +249,6 @@ async function writeClipboardText(text: string) { } } -function resolveLogLevel(line: string): LogLevel { - if (/\b(ERROR|FAILED|FAILURE)\b|错误|失败/i.test(line)) return 'error'; - if (/\b(WARN|WARNING)\b|警告/i.test(line)) return 'warning'; - if (/\b(SUCCESS|SUCCEEDED|COMPLETED)\b|成功|完成/i.test(line)) return 'success'; - if (/\bINFO\b|信息/i.test(line)) return 'info'; - return 'default'; -} - function confirmDelete(recordId: string) { dialog.warning({ title: '删除历史记录', diff --git a/frontend/src/components/ScriptPanel.vue b/frontend/src/components/ScriptPanel.vue index cde304b..6445c02 100644 --- a/frontend/src/components/ScriptPanel.vue +++ b/frontend/src/components/ScriptPanel.vue @@ -4,14 +4,8 @@
-
- {{ cursorText }} - {{ editorStatus }} - {{ lineCount }} 行 -
- -
-
+
+
脚本执行进度 @@ -22,10 +16,23 @@
- +
+
{{ line.text }}
+
+ +
+ {{ cursorText }} + {{ editorStatus }} + {{ lineCount }} 行 +
@@ -39,12 +46,17 @@ import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution'; import { apiGet, apiPost } from '../api/client'; import type { ApiMessage, ScriptContent, TaskStatus } from '../types'; +import { toColoredLogLines } from '../composables/logLines'; import { resetPageHeader, setPageHeader } from '../composables/pageHeader'; type MonacoGlobal = typeof globalThis & { MonacoEnvironment?: { getWorker: (_moduleId: string, _label: string) => Worker; }; + __capacityReportScriptTaskState?: { + taskId: ReturnType>; + taskStatus: ReturnType>; + }; }; (globalThis as MonacoGlobal).MonacoEnvironment = { @@ -65,13 +77,21 @@ const isModified = ref(false); const loading = ref(false); const saving = ref(false); const executing = ref(false); -const taskId = ref(''); -const taskStatus = ref(null); let timer: number | undefined; let themeObserver: MutationObserver | undefined; let applyingRemoteContent = false; +const globalWindow = window as MonacoGlobal; +const scriptTaskState = globalWindow.__capacityReportScriptTaskState || { + taskId: ref(''), + taskStatus: ref(null) +}; +globalWindow.__capacityReportScriptTaskState = scriptTaskState; +const taskId = scriptTaskState.taskId; +const taskStatus = scriptTaskState.taskStatus; + const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志'); +const logLines = computed(() => toColoredLogLines(logText.value, '等待执行日志')); const statusClass = computed(() => { if (taskStatus.value?.status === 'completed') return 'completed'; if (taskStatus.value?.status === 'failed') return 'failed'; @@ -123,6 +143,9 @@ onMounted(async () => { createEditor(); observeTheme(); await loadScript(); + if (taskId.value && taskStatus.value && !['completed', 'failed'].includes(taskStatus.value.status)) { + startPolling(); + } }); onBeforeUnmount(() => { diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue index 21b677e..33e2e60 100644 --- a/frontend/src/components/SettingsPanel.vue +++ b/frontend/src/components/SettingsPanel.vue @@ -659,9 +659,9 @@ async function changePassword() { async function downloadConfig() { try { const filename = 'Configure.json'; - const completed = await downloadGet('/api/config/download', filename); - if (completed) { - showDownloadCompleteDialog(dialog, filename); + const result = await downloadGet('/api/config/download', filename); + if (result.saved) { + showDownloadCompleteDialog(dialog, filename, result.path); } } catch (error) { message.error(error instanceof Error ? error.message : '下载配置失败'); diff --git a/frontend/src/composables/downloadFeedback.ts b/frontend/src/composables/downloadFeedback.ts index d7b56ac..e683b12 100644 --- a/frontend/src/composables/downloadFeedback.ts +++ b/frontend/src/composables/downloadFeedback.ts @@ -1,21 +1,32 @@ -interface DownloadDialog { - success(options: { - title: string; - content: string; - positiveText?: string; - closable?: boolean; - maskClosable?: boolean; - }): unknown; -} +import { h, type VNodeChild } from 'vue'; +import type { useDialog } from 'naive-ui'; -export function showDownloadCompleteDialog(dialog: DownloadDialog, filename?: string): void { +import { openPathInFileManager } from '../api/client'; + +type DownloadDialog = ReturnType; + +export function showDownloadCompleteDialog(dialog: DownloadDialog, filename?: string, path?: string): void { const safeName = filename?.trim(); + const safePath = path?.trim(); + const content = safePath + ? (): VNodeChild => h('div', { class: 'download-complete-content' }, [ + h('p', safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。'), + h('button', { + class: 'download-path-link', + type: 'button', + title: safePath, + onClick: () => void openPathInFileManager(safePath) + }, safePath) + ]) + : safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。'; dialog.success({ title: '下载完成', - content: safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。', - positiveText: '知道了', + content, + positiveText: safePath ? '打开所在文件夹' : '知道了', + negativeText: safePath ? '关闭' : undefined, closable: true, - maskClosable: true + maskClosable: true, + onPositiveClick: safePath ? () => openPathInFileManager(safePath) : undefined }); } diff --git a/frontend/src/composables/logLines.ts b/frontend/src/composables/logLines.ts new file mode 100644 index 0000000..f46b8c4 --- /dev/null +++ b/frontend/src/composables/logLines.ts @@ -0,0 +1,28 @@ +export type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error'; + +export interface ColoredLogLine { + text: string; + level: LogLevel; +} + +export function toColoredLogLines(text: string, fallback = '暂无日志'): ColoredLogLine[] { + const lines = text ? text.split(/\r?\n/) : [fallback]; + return lines.map(line => ({ + text: line, + level: resolveLogLevel(line) + })); +} + +export function resolveLogLevel(line: string): LogLevel { + const marker = line.match(/\[(ERROR|WARN|WARNING|SUCCESS|INFO)\]/i)?.[1]?.toUpperCase(); + if (marker === 'ERROR') return 'error'; + if (marker === 'WARN' || marker === 'WARNING') return 'warning'; + if (marker === 'SUCCESS') return 'success'; + if (marker === 'INFO') return 'info'; + if (/\b(ERROR|FAILED|FAILURE)\b|错误|失败/i.test(line)) return 'error'; + if (/\b(WARN|WARNING)\b|警告/i.test(line)) return 'warning'; + if (/\b(SUCCESS|SUCCEEDED|COMPLETED)\b|成功|已完成|处理完成|下载完成|执行完成/i.test(line)) { + return 'success'; + } + return 'default'; +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 8115da8..df77d22 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -9,7 +9,7 @@ const ScriptPanel = () => import('./components/ScriptPanel.vue'); export const routes: RouteRecordRaw[] = [ { path: '/', redirect: { name: 'workflow' } }, - { path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据上传与处理' } }, + { path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据处理' } }, { path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } }, { path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } }, { path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } }, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 06c35e8..a10fc6e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -26,6 +26,7 @@ --td-bg-color-component-disabled: #eeeeee; --td-border-color: #dcdcdc; --td-border-color-light: #e7e7e7; + --td-scrollbar-color: rgba(0, 0, 0, 0.2); --td-mask-color: rgba(0, 0, 0, 0.6); --td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.05), 0 4px 5px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.12); --td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.05), 0 8px 10px 1px rgba(0, 0, 0, 0.06), 0 5px 5px -3px rgba(0, 0, 0, 0.1); @@ -61,6 +62,7 @@ --td-bg-color-component-disabled: #383838; --td-border-color: #5e5e5e; --td-border-color-light: #4c4c4c; + --td-scrollbar-color: rgba(255, 255, 255, 0.28); --td-shadow-1: 0 1px 10px rgba(0, 0, 0, 0.15), 0 4px 5px rgba(0, 0, 0, 0.22), 0 2px 4px -1px rgba(0, 0, 0, 0.32); --td-shadow-2: 0 3px 14px 2px rgba(0, 0, 0, 0.15), 0 8px 10px 1px rgba(0, 0, 0, 0.18), 0 5px 5px -3px rgba(0, 0, 0, 0.28); --td-shadow-3: 0 6px 30px 5px rgba(0, 0, 0, 0.15), 0 16px 24px 2px rgba(0, 0, 0, 0.12), 0 8px 10px -5px rgba(0, 0, 0, 0.24); @@ -473,6 +475,12 @@ select { padding: 24px 32px; } +.upload-page-body { + display: flex; + min-height: 0; + flex-direction: column; +} + .upload-zone { display: flex; position: relative; @@ -797,9 +805,14 @@ select { } .process-section { + min-height: 0; margin-top: 12px; } +.upload-page-body > .process-section { + flex: 1 1 auto; +} + .upload-process-card { background: var(--td-bg-color-container); border: 1px solid var(--td-border-color-light); @@ -829,6 +842,7 @@ select { .card-body { padding: 20px; + min-height: 0; } .process-status { @@ -857,20 +871,40 @@ select { } .log-container { - height: calc(100vh - 280px); - min-height: 300px; + height: clamp(200px, calc(100vh - 400px), 520px); + min-height: 180px; margin-top: 16px; padding: 16px; - overflow-y: auto; + overflow: auto; background: var(--td-bg-color-page); border: 1px solid var(--td-border-color-light); border-radius: var(--td-radius-default); + scrollbar-color: var(--td-scrollbar-color) transparent; + scrollbar-width: thin; } .process-section.finished .log-container { - height: auto; - min-height: 180px; - max-height: clamp(220px, calc(100vh - 620px), 460px); + height: clamp(180px, calc(100vh - 460px), 380px); + min-height: 160px; +} + +.upload-page-body > .process-section .upload-process-card { + display: flex; + height: 100%; + max-height: 100%; + min-height: 0; + flex-direction: column; +} + +.upload-page-body > .process-section .card-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.upload-page-body > .process-section .log-container { + flex: 1 1 auto; } .log-content { @@ -1063,13 +1097,15 @@ select { } .colored-log-panel { - height: 360px; - overflow: scroll; + height: 300px; + overflow: auto; padding: 12px; - color: #d7dde7; - background: #111820; + color: var(--td-text-color-primary); + background: var(--td-bg-color-page); + border: 1px solid var(--td-border-color-light); border-radius: var(--td-radius-default); - scrollbar-color: #5f6f83 #0b1118; + scrollbar-color: var(--td-scrollbar-color) transparent; + scrollbar-width: thin; } .colored-log-panel::-webkit-scrollbar { @@ -1078,22 +1114,22 @@ select { } .colored-log-panel::-webkit-scrollbar-track { - background: #0b1118; + background: transparent; border-radius: 5px; } .colored-log-panel::-webkit-scrollbar-thumb { - background: #5f6f83; - border: 2px solid #0b1118; + background: var(--td-scrollbar-color); + border: 2px solid var(--td-bg-color-page); border-radius: 5px; } .colored-log-panel::-webkit-scrollbar-thumb:hover { - background: #7b8da3; + background: var(--td-text-color-placeholder); } .colored-log-panel::-webkit-scrollbar-corner { - background: #0b1118; + background: var(--td-bg-color-page); } .colored-log-content { @@ -1108,32 +1144,57 @@ select { .colored-log-line { display: block; - color: #d7dde7; + color: var(--td-text-color-primary); white-space: pre; } .colored-log-line.log-level-info { - color: #62a8ff; + color: var(--td-text-color-primary); } .colored-log-line.log-level-success { - color: #38d996; + color: var(--td-success-color); } .colored-log-line.log-level-warning { - color: #f6c453; + color: var(--td-warning-color); } .colored-log-line.log-level-error { - color: #ff6b7a; + color: var(--td-error-color); } .script-log { - min-height: 320px; - padding: 12px; - color: #d7dde7; - background: #111820; - border-radius: var(--td-radius-default); + height: 100%; +} + +.download-complete-content { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; +} + +.download-complete-content p { + margin: 0; +} + +.download-path-link { + overflow: hidden; + padding: 0; + color: var(--td-brand-color); + font: inherit; + font-family: var(--td-font-family-mono); + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + background: transparent; + border: 0; +} + +.download-path-link:hover { + text-decoration: underline; } /* ==================== Settings ==================== */ @@ -1550,7 +1611,7 @@ select { } .script-editor-container { - flex: 1; + flex: 1 1 auto; min-height: 0; margin: 16px 16px 0; overflow: hidden; @@ -1588,17 +1649,28 @@ select { } .script-process-section { - flex: 0 0 auto; - margin: 0 16px 16px; + flex: 0 1 clamp(168px, 26vh, 280px); + min-height: 148px; + margin: 8px 16px 0; + overflow: hidden; +} + +.script-process-card { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; } .script-process-section .card-body { + flex: 1; + min-height: 0; padding: 12px 16px 16px; } .script-log { - height: 240px; - min-height: 240px; + height: 100%; + min-height: 0; } /* ==================== Responsive ==================== */ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a1aa819..ac19d67 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -237,6 +237,7 @@ dependencies = [ name = "capacity-report-desktop" version = "3.0.0" dependencies = [ + "open", "reqwest", "serde", "sysinfo", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6bb2921..4cc3e01 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" tauri-build = { version = "2", features = [] } [dependencies] +open = "5.3.5" reqwest = { version = "0.13", default-features = false, features = ["stream"] } serde = { version = "1", features = ["derive"] } sysinfo = { version = "0.38.4", default-features = false, features = ["system"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 34dd098..b6898ed 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -267,6 +267,21 @@ async fn download_to_file( }) } +#[tauri::command] +fn open_path_in_file_manager(path: String) -> Result<(), String> { + let target = PathBuf::from(path); + let directory = if target.is_dir() { + target + } else { + target + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "Unable to resolve download directory".to_string())? + }; + + open::that(directory).map_err(|error| format!("Unable to open download directory: {error}")) +} + async fn write_download(request: DownloadFileRequest, file_path: &Path) -> Result<(), String> { let method = reqwest::Method::from_bytes(request.method.as_bytes()) .map_err(|error| format!("Invalid download method: {error}"))?; @@ -332,7 +347,10 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) - .invoke_handler(tauri::generate_handler![download_to_file]) + .invoke_handler(tauri::generate_handler![ + download_to_file, + open_path_in_file_manager + ]) .manage(ServerState(Mutex::new(None))) .setup(|app| { start_server(app)?;