mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-20 21:43:56 +08:00
🐛 fix(external-sql): 修复外部 SQL 文件丢失后标签无法关闭
- 后端读取 SQL 文件失败时返回 file_not_found 结构化错误码 - 前端识别文件被删除或移动的场景,允许用户确认关闭标签 - 保留权限、网络盘异常等非缺失错误的关闭拦截,避免误丢草稿 - 补充前后端测试覆盖缺失文件识别与标签关闭提示 Close #566
This commit is contained in:
@@ -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 文件修改?'");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 文件"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user