🐛 fix(external-sql): 修复外部 SQL 文件丢失后标签无法关闭

- 后端读取 SQL 文件失败时返回 file_not_found 结构化错误码
- 前端识别文件被删除或移动的场景,允许用户确认关闭标签
- 保留权限、网络盘异常等非缺失错误的关闭拦截,避免误丢草稿
- 补充前后端测试覆盖缺失文件识别与标签关闭提示
Close #566
This commit is contained in:
Syngnat
2026-06-16 08:48:43 +08:00
parent c70eb7157f
commit 0816702084
6 changed files with 175 additions and 52 deletions

View File

@@ -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 文件修改?'");

View File

@@ -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 }) => (
<>
<Button
onClick={() => {
destroyConfirm?.();
closeConfirmedTabsAndClearDrafts();
}}
>
</Button>
<CancelBtn />
<OkBtn />
</>
),
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 }) => (
<>
<Button
onClick={() => {
destroyConfirm?.();
closeConfirmedTabsAndClearDrafts();
}}
>
</Button>
<CancelBtn />
<OkBtn />
</>
),
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) => {

View File

@@ -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);
});
});

View File

@@ -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<TabData, 'type' | 'filePath'> | 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<string, unknown>;
if (payload.success === true) return false;
const data = payload.data;
if (data && typeof data === 'object') {
const errorCode = toTrimmedString((data as Record<string, unknown>).errorCode).toLowerCase();
if (errorCode === SQL_FILE_NOT_FOUND_ERROR_CODE) {
return true;
}
}
return isSQLFileMissingErrorMessage(payload.message);
};

View File

@@ -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 文件"}

View File

@@ -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 {