diff --git a/frontend/src/components/TabManager.hover.test.tsx b/frontend/src/components/TabManager.hover.test.tsx
index 420ba74..2a2c4c6 100644
--- a/frontend/src/components/TabManager.hover.test.tsx
+++ b/frontend/src/components/TabManager.hover.test.tsx
@@ -208,6 +208,11 @@ describe('TabManager hover info', () => {
const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8');
expect(source).toContain('ReadSQLFile(filePath)');
+ expect(source).toContain('isSQLFileMissingReadResult(res)');
+ expect(source).toContain('isSQLFileMissingErrorMessage(errorMessage)');
+ expect(source).toContain("title: '关闭已丢失的 SQL 文件标签?'");
+ expect(source).toContain('关闭后将丢弃标签内的本地草稿');
+ expect(source).toContain('confirmDirtyTabsOrClose();');
expect(source).toContain("getSQLFileTabDraft(tab.id, String(tab.query ?? ''))");
expect(source).toContain('hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))');
expect(source).toContain("title: '保存 SQL 文件修改?'");
diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx
index afee795..b231dce 100644
--- a/frontend/src/components/TabManager.tsx
+++ b/frontend/src/components/TabManager.tsx
@@ -34,6 +34,8 @@ import { ReadSQLFile, WriteSQLFile } from '../../wailsjs/go/app/App';
import {
getSQLFileTabPath,
hasSQLFileTabUnsavedChanges,
+ isSQLFileMissingErrorMessage,
+ isSQLFileMissingReadResult,
isSQLFileQueryTab,
normalizeSQLFileReadContent,
} from '../utils/sqlFileTabDirty';
@@ -450,12 +452,17 @@ const TabManager: React.FC = React.memo(() => {
};
const dirtyTabs: Array<{ tab: TabData; draft: string }> = [];
+ const missingFileTabs: Array<{ tab: TabData; filePath: string }> = [];
for (const tab of candidateTabs) {
const filePath = getSQLFileTabPath(tab);
if (!filePath) continue;
try {
const res = await ReadSQLFile(filePath);
if (!res.success) {
+ if (isSQLFileMissingReadResult(res)) {
+ missingFileTabs.push({ tab, filePath });
+ continue;
+ }
message.error(`读取 SQL 文件失败,已取消关闭:${res.message || filePath}`);
return;
}
@@ -464,64 +471,93 @@ const TabManager: React.FC = React.memo(() => {
dirtyTabs.push({ tab, draft });
}
} catch (error) {
- message.error('读取 SQL 文件失败,已取消关闭:' + (error instanceof Error ? error.message : String(error)));
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ if (isSQLFileMissingErrorMessage(errorMessage)) {
+ missingFileTabs.push({ tab, filePath });
+ continue;
+ }
+ message.error('读取 SQL 文件失败,已取消关闭:' + errorMessage);
return;
}
}
- if (dirtyTabs.length === 0) {
- closeConfirmedTabsAndClearDrafts();
+ const confirmDirtyTabsOrClose = () => {
+ if (dirtyTabs.length === 0) {
+ closeConfirmedTabsAndClearDrafts();
+ return;
+ }
+
+ const firstDirtyTab = dirtyTabs[0].tab;
+ const dirtyFilePath = getSQLFileTabPath(firstDirtyTab);
+ const dirtyLabel = dirtyTabs.length === 1
+ ? `“${firstDirtyTab.title || dirtyFilePath}”`
+ : `${dirtyTabs.length} 个 SQL 文件`;
+
+ let destroyConfirm: (() => void) | null = null;
+ const confirmRef = Modal.confirm({
+ title: '保存 SQL 文件修改?',
+ content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`,
+ okText: '保存并关闭',
+ cancelText: '取消',
+ closable: true,
+ maskClosable: true,
+ okButtonProps: { type: 'primary' },
+ footer: (_, { OkBtn, CancelBtn }) => (
+ <>
+
+
+
+ >
+ ),
+ onOk: async () => {
+ try {
+ for (const { tab, draft } of dirtyTabs) {
+ const filePath = getSQLFileTabPath(tab);
+ if (!filePath) continue;
+ const res = await WriteSQLFile(filePath, draft);
+ if (!res.success) {
+ throw new Error(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`);
+ }
+ }
+ message.success('SQL 文件已保存');
+ closeConfirmedTabsAndClearDrafts();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : String(error));
+ throw error;
+ }
+ },
+ });
+ destroyConfirm = confirmRef.destroy;
+ };
+
+ if (missingFileTabs.length > 0) {
+ const firstMissing = missingFileTabs[0];
+ const missingLabel = missingFileTabs.length === 1
+ ? `“${firstMissing.tab.title || firstMissing.filePath}”`
+ : `${missingFileTabs.length} 个 SQL 文件标签`;
+ Modal.confirm({
+ title: '关闭已丢失的 SQL 文件标签?',
+ content: `${missingLabel} 对应的外部 SQL 文件已不存在或已被移动,关闭后将丢弃标签内的本地草稿。`,
+ okText: dirtyTabs.length > 0 ? '继续关闭' : '关闭标签',
+ cancelText: '取消',
+ closable: true,
+ maskClosable: true,
+ okButtonProps: { danger: true },
+ onOk: () => {
+ confirmDirtyTabsOrClose();
+ },
+ });
return;
}
- const firstDirtyTab = dirtyTabs[0].tab;
- const dirtyFilePath = getSQLFileTabPath(firstDirtyTab);
- const dirtyLabel = dirtyTabs.length === 1
- ? `“${firstDirtyTab.title || dirtyFilePath}”`
- : `${dirtyTabs.length} 个 SQL 文件`;
-
- let destroyConfirm: (() => void) | null = null;
- const confirmRef = Modal.confirm({
- title: '保存 SQL 文件修改?',
- content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`,
- okText: '保存并关闭',
- cancelText: '取消',
- closable: true,
- maskClosable: true,
- okButtonProps: { type: 'primary' },
- footer: (_, { OkBtn, CancelBtn }) => (
- <>
-
-
-
- >
- ),
- onOk: async () => {
- try {
- for (const { tab, draft } of dirtyTabs) {
- const filePath = getSQLFileTabPath(tab);
- if (!filePath) continue;
- const res = await WriteSQLFile(filePath, draft);
- if (!res.success) {
- throw new Error(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`);
- }
- }
- message.success('SQL 文件已保存');
- closeConfirmedTabsAndClearDrafts();
- } catch (error) {
- message.error(error instanceof Error ? error.message : String(error));
- throw error;
- }
- },
- });
- destroyConfirm = confirmRef.destroy;
+ confirmDirtyTabsOrClose();
}, []);
const closeTabsWithSQLFilePrompt = useCallback((targetIds: string[], closeConfirmedTabs: () => void) => {
diff --git a/frontend/src/utils/sqlFileTabDirty.test.ts b/frontend/src/utils/sqlFileTabDirty.test.ts
index 83250eb..bed3ac6 100644
--- a/frontend/src/utils/sqlFileTabDirty.test.ts
+++ b/frontend/src/utils/sqlFileTabDirty.test.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest';
import {
getSQLFileTabPath,
hasSQLFileTabUnsavedChanges,
+ isSQLFileMissingErrorMessage,
+ isSQLFileMissingReadResult,
isSQLFileQueryTab,
normalizeSQLFileReadContent,
} from './sqlFileTabDirty';
@@ -34,4 +36,24 @@ describe('sqlFileTabDirty', () => {
query: 'select 2;',
} as any, 'select 1;')).toBe(true);
});
+
+ it('detects missing SQL file read failures by structured error code', () => {
+ expect(isSQLFileMissingReadResult({
+ success: false,
+ message: '无法读取文件信息: stat /tmp/missing.sql: no such file or directory',
+ data: { errorCode: 'file_not_found', filePath: '/tmp/missing.sql' },
+ })).toBe(true);
+
+ expect(isSQLFileMissingReadResult({
+ success: false,
+ message: '无法读取文件信息: permission denied',
+ data: { filePath: '/tmp/report.sql' },
+ })).toBe(false);
+ });
+
+ it('keeps platform-specific missing file messages as a fallback', () => {
+ expect(isSQLFileMissingErrorMessage('GetFileAttributesEx C:\\Users\\me\\missing.sql: The system cannot find the file specified.')).toBe(true);
+ expect(isSQLFileMissingErrorMessage('stat /Users/me/missing.sql: no such file or directory')).toBe(true);
+ expect(isSQLFileMissingErrorMessage('无法读取文件信息: 权限不足')).toBe(false);
+ });
});
diff --git a/frontend/src/utils/sqlFileTabDirty.ts b/frontend/src/utils/sqlFileTabDirty.ts
index c8442ac..473f9fd 100644
--- a/frontend/src/utils/sqlFileTabDirty.ts
+++ b/frontend/src/utils/sqlFileTabDirty.ts
@@ -2,6 +2,8 @@ import type { TabData } from '../types';
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
+export const SQL_FILE_NOT_FOUND_ERROR_CODE = 'file_not_found';
+
export const getSQLFileTabPath = (tab: Pick | null | undefined): string => {
if (!tab || tab.type !== 'query') return '';
return toTrimmedString(tab.filePath);
@@ -28,3 +30,35 @@ export const hasSQLFileTabUnsavedChanges = (
if (!isSQLFileQueryTab(tab)) return false;
return String(tab.query ?? '') !== diskContent;
};
+
+const SQL_FILE_MISSING_MESSAGE_PATTERNS = [
+ 'no such file or directory',
+ 'cannot find the file specified',
+ 'system cannot find the file specified',
+ 'does not exist',
+ 'not exist',
+ '系统找不到指定的文件',
+ '文件不存在',
+];
+
+export const isSQLFileMissingErrorMessage = (message: unknown): boolean => {
+ const normalizedMessage = toTrimmedString(message).toLowerCase();
+ if (!normalizedMessage) return false;
+ return SQL_FILE_MISSING_MESSAGE_PATTERNS.some((pattern) => normalizedMessage.includes(pattern));
+};
+
+export const isSQLFileMissingReadResult = (result: unknown): boolean => {
+ if (!result || typeof result !== 'object') return false;
+ const payload = result as Record;
+ if (payload.success === true) return false;
+
+ const data = payload.data;
+ if (data && typeof data === 'object') {
+ const errorCode = toTrimmedString((data as Record).errorCode).toLowerCase();
+ if (errorCode === SQL_FILE_NOT_FOUND_ERROR_CODE) {
+ return true;
+ }
+ }
+
+ return isSQLFileMissingErrorMessage(payload.message);
+};
diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go
index 4fc0d3e..c8390ef 100644
--- a/internal/app/methods_file.go
+++ b/internal/app/methods_file.go
@@ -30,6 +30,8 @@ import (
const minExportQueryTimeout = 5 * time.Minute
const minClickHouseExportQueryTimeout = 2 * time.Hour
const maxSQLFileSizeBytes int64 = 50 * 1024 * 1024
+
+const sqlFileErrorCodeNotFound = "file_not_found"
const sqlFileBatchMaxStatements = 1000
const sqlFileBatchMaxBytes = 4 * 1024 * 1024
const sqlFileProgressStatementInterval = 100
@@ -321,7 +323,11 @@ func readSQLFileByPath(filePath string) connection.QueryResult {
fi, err := os.Stat(selection)
if err != nil {
- return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)}
+ data := map[string]interface{}{"filePath": selection}
+ if os.IsNotExist(err) {
+ data["errorCode"] = sqlFileErrorCodeNotFound
+ }
+ return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err), Data: data}
}
if fi.IsDir() {
return connection.QueryResult{Success: false, Message: "所选路径不是 SQL 文件"}
diff --git a/internal/app/methods_file_sql_directory_test.go b/internal/app/methods_file_sql_directory_test.go
index 2b7aae7..397b6e5 100644
--- a/internal/app/methods_file_sql_directory_test.go
+++ b/internal/app/methods_file_sql_directory_test.go
@@ -302,6 +302,26 @@ func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) {
}
}
+func TestReadSQLFileByPathMarksMissingFile(t *testing.T) {
+ filePath := filepath.Join(t.TempDir(), "missing.sql")
+
+ result := readSQLFileByPath(filePath)
+ if result.Success {
+ t.Fatalf("expected missing sql file read to fail, got %#v", result)
+ }
+
+ data, ok := result.Data.(map[string]interface{})
+ if !ok {
+ t.Fatalf("expected error metadata map, got %#v", result.Data)
+ }
+ if data["errorCode"] != sqlFileErrorCodeNotFound {
+ t.Fatalf("expected file_not_found error code, got %#v", data["errorCode"])
+ }
+ if data["filePath"] != filePath {
+ t.Fatalf("expected filePath %q, got %#v", filePath, data["filePath"])
+ }
+}
+
func TestReadSQLFileWithMetadataByPathReturnsSmallFileContentAndPath(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "report.sql")
if err := os.WriteFile(filePath, []byte("select 1;"), 0o644); err != nil {