feat: 优化桌面文件默认保存路径

This commit is contained in:
SkyJourney
2026-08-01 04:09:21 +08:00
parent dc026835b7
commit 7f2c169405
5 changed files with 371 additions and 16 deletions
@@ -75,6 +75,7 @@ import {
type WindowState
} from "./window-state.js";
import { getWindowCloseDecision } from "./window-close-policy.js";
import { SaveLocationStore } from "./save-location-state.js";
const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 };
const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 };
@@ -113,6 +114,8 @@ export interface DesktopApplicationControllerOptions {
iconPath: string;
themeDirectory: string;
windowStatePath: string;
saveLocationStatePath: string;
documentsDirectory: string;
}
function focusWindow(window: BrowserWindow) {
@@ -134,6 +137,7 @@ export class DesktopApplicationController {
readonly #iconPath: string;
readonly #themeDirectory: string;
readonly #windowStatePath: string;
readonly #saveLocationStore: SaveLocationStore;
readonly #pdfGenerator: ElectronPdfGenerator;
readonly #docxExportService: DocxExportService;
readonly #docxMediaEngine: ElectronDocxMediaEngine;
@@ -153,6 +157,10 @@ export class DesktopApplicationController {
this.#iconPath = options.iconPath;
this.#themeDirectory = options.themeDirectory;
this.#windowStatePath = options.windowStatePath;
this.#saveLocationStore = new SaveLocationStore(
options.saveLocationStatePath,
options.documentsDirectory
);
this.#pdfGenerator = new ElectronPdfGenerator({
renderUrl: options.renderUrl,
preloadPath: options.pdfPreloadPath
@@ -181,12 +189,22 @@ export class DesktopApplicationController {
}
async initialize() {
this.#savedWindowState = await loadWindowState(
this.#windowStatePath
);
const [savedWindowState] = await Promise.all([
loadWindowState(this.#windowStatePath),
this.#saveLocationStore.initialize()
]);
this.#savedWindowState = savedWindowState;
this.#registerIpcHandlers();
}
async #recordSuccessfulFile(filePath: string) {
await this.#saveLocationStore
.recordSuccessfulFile(filePath)
.catch((error: unknown) => {
console.warn("无法持久化最近保存目录", error);
});
}
async createWindow(filePath?: string) {
let pendingSnapshot: MarkdownFileSnapshot | undefined;
if (filePath) {
@@ -733,9 +751,10 @@ export class DesktopApplicationController {
? "另存为 Markdown"
: "保存 Markdown",
defaultPath:
forceSaveAs && session.currentFilePath
? session.currentFilePath
: path.join(app.getPath("documents"), suggestedName),
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
suggestedName
),
filters: [
{
name: "Markdown 文件",
@@ -788,6 +807,7 @@ export class DesktopApplicationController {
throw new Error("该 Markdown 已在另一个窗口中打开");
}
this.#commitSnapshot(session, snapshot);
await this.#recordSuccessfulFile(targetPath);
return { fileName: snapshot.document.fileName };
};
@@ -905,16 +925,18 @@ export class DesktopApplicationController {
: `${path.basename(unsafeFileName)}.pdf`;
const selection = await dialog.showSaveDialog(session.window, {
title: "导出 PDF",
defaultPath: path.join(
app.getPath("documents"),
suggestedName
),
defaultPath:
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
suggestedName
),
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
});
if (selection.canceled || !selection.filePath) {
return false;
}
await writeFile(selection.filePath, unsafePdf);
await this.#recordSuccessfulFile(selection.filePath);
return true;
}
);
@@ -971,10 +993,11 @@ export class DesktopApplicationController {
session.window,
{
title: "导出 DOCX",
defaultPath: path.join(
app.getPath("documents"),
generated.fileName
),
defaultPath:
await this.#saveLocationStore.resolveDefaultPath(
session.currentFilePath,
generated.fileName
),
filters: [
{
name: "Word 文档",
@@ -996,6 +1019,7 @@ export class DesktopApplicationController {
selection.filePath,
generated.docx
);
await this.#recordSuccessfulFile(saved.targetPath);
return {
ok: true,
saved: true,
+6 -1
View File
@@ -337,7 +337,12 @@ if (!hasSingleInstanceLock) {
windowStatePath: path.join(
app.getPath("userData"),
"window-state.json"
)
),
saveLocationStatePath: path.join(
app.getPath("userData"),
"save-location.json"
),
documentsDirectory: app.getPath("documents")
});
await applicationController.initialize();
+164
View File
@@ -0,0 +1,164 @@
import {
mkdir,
readFile,
rename,
rm,
stat,
writeFile
} from "node:fs/promises";
import path from "node:path";
const SAVE_LOCATION_STATE_VERSION = 1;
export interface SaveLocationState {
version: 1;
directory: string;
}
export interface ResolveDefaultSavePathOptions {
sourceFilePath?: string;
lastSaveDirectory?: string;
documentsDirectory: string;
suggestedFileName: string;
}
export function parseSaveLocationState(
value: unknown
): SaveLocationState | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const state = value as Partial<SaveLocationState>;
if (
state.version !== SAVE_LOCATION_STATE_VERSION ||
typeof state.directory !== "string" ||
!path.isAbsolute(state.directory)
) {
return undefined;
}
return {
version: SAVE_LOCATION_STATE_VERSION,
directory: path.resolve(state.directory)
};
}
async function isAvailableDirectory(directory: string | undefined) {
if (!directory || !path.isAbsolute(directory)) {
return false;
}
try {
return (await stat(directory)).isDirectory();
} catch {
return false;
}
}
function assertSuggestedFileName(value: string) {
const fileName = path.basename(value.trim());
if (!fileName || fileName === ".") {
throw new Error("默认保存文件名无效");
}
return fileName;
}
export async function resolveDefaultSavePath(
options: ResolveDefaultSavePathOptions
) {
const fileName = assertSuggestedFileName(
options.suggestedFileName
);
const sourceDirectory = options.sourceFilePath
? path.dirname(path.resolve(options.sourceFilePath))
: undefined;
if (await isAvailableDirectory(sourceDirectory)) {
return path.join(sourceDirectory!, fileName);
}
if (await isAvailableDirectory(options.lastSaveDirectory)) {
return path.join(options.lastSaveDirectory!, fileName);
}
return path.join(
path.resolve(options.documentsDirectory),
fileName
);
}
export async function loadSaveLocationState(filePath: string) {
try {
return parseSaveLocationState(
JSON.parse(await readFile(filePath, "utf8")) as unknown
);
} catch {
return undefined;
}
}
export async function saveSaveLocationState(
filePath: string,
directory: string
) {
const state = parseSaveLocationState({
version: SAVE_LOCATION_STATE_VERSION,
directory
});
if (!state) {
throw new Error("最近保存目录无效");
}
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = `${filePath}.${process.pid}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify(state, null, 2)}\n`,
"utf8"
);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
return state;
}
export class SaveLocationStore {
readonly #statePath: string;
readonly #documentsDirectory: string;
#lastSaveDirectory: string | undefined;
#pendingWrite = Promise.resolve();
constructor(statePath: string, documentsDirectory: string) {
this.#statePath = statePath;
this.#documentsDirectory = documentsDirectory;
}
async initialize() {
this.#lastSaveDirectory = (
await loadSaveLocationState(this.#statePath)
)?.directory;
}
resolveDefaultPath(
sourceFilePath: string | undefined,
suggestedFileName: string
) {
return resolveDefaultSavePath({
documentsDirectory: this.#documentsDirectory,
suggestedFileName,
...(sourceFilePath ? { sourceFilePath } : {}),
...(this.#lastSaveDirectory
? { lastSaveDirectory: this.#lastSaveDirectory }
: {})
});
}
async recordSuccessfulFile(filePath: string) {
const directory = path.dirname(path.resolve(filePath));
this.#lastSaveDirectory = directory;
const write = this.#pendingWrite
.catch(() => undefined)
.then(() =>
saveSaveLocationState(this.#statePath, directory)
);
this.#pendingWrite = write.then(() => undefined);
await write;
}
}
@@ -0,0 +1,153 @@
import {
mkdir,
mkdtemp,
readFile,
rm
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
SaveLocationStore,
loadSaveLocationState,
parseSaveLocationState,
resolveDefaultSavePath,
saveSaveLocationState
} from "../src/save-location-state.js";
let temporaryDirectory: string | undefined;
async function createTemporaryDirectory() {
temporaryDirectory = await mkdtemp(
path.join(tmpdir(), "md-to-pdf-save-location-")
);
return temporaryDirectory;
}
afterEach(async () => {
if (temporaryDirectory) {
await rm(temporaryDirectory, { recursive: true, force: true });
temporaryDirectory = undefined;
}
});
describe("桌面默认保存位置", () => {
it("只接受版本匹配的绝对目录状态", () => {
expect(
parseSaveLocationState({
version: 1,
directory: path.resolve("C:/Documents")
})
).toEqual({
version: 1,
directory: path.resolve("C:/Documents")
});
expect(
parseSaveLocationState({ version: 2, directory: "C:/Documents" })
).toBeUndefined();
expect(
parseSaveLocationState({ version: 1, directory: "relative" })
).toBeUndefined();
});
it("优先使用有效的源文件目录", async () => {
const root = await createTemporaryDirectory();
const sourceDirectory = path.join(root, "source");
const historyDirectory = path.join(root, "history");
const documentsDirectory = path.join(root, "documents");
await Promise.all([
mkdir(sourceDirectory),
mkdir(historyDirectory),
mkdir(documentsDirectory)
]);
await expect(
resolveDefaultSavePath({
sourceFilePath: path.join(sourceDirectory, "source.md"),
lastSaveDirectory: historyDirectory,
documentsDirectory,
suggestedFileName: "report.pdf"
})
).resolves.toBe(path.join(sourceDirectory, "report.pdf"));
});
it("源目录不可用时使用最近成功目录", async () => {
const root = await createTemporaryDirectory();
const historyDirectory = path.join(root, "history");
const documentsDirectory = path.join(root, "documents");
await Promise.all([
mkdir(historyDirectory),
mkdir(documentsDirectory)
]);
await expect(
resolveDefaultSavePath({
sourceFilePath: path.join(root, "missing", "source.md"),
lastSaveDirectory: historyDirectory,
documentsDirectory,
suggestedFileName: "report.docx"
})
).resolves.toBe(path.join(historyDirectory, "report.docx"));
});
it("源目录和历史目录均不可用时回退到文档目录", async () => {
const root = await createTemporaryDirectory();
const documentsDirectory = path.join(root, "documents");
await mkdir(documentsDirectory);
await expect(
resolveDefaultSavePath({
lastSaveDirectory: path.join(root, "missing"),
documentsDirectory,
suggestedFileName: "draft.md"
})
).resolves.toBe(path.join(documentsDirectory, "draft.md"));
});
it("以原子 JSON 状态持久化并恢复最近目录", async () => {
const root = await createTemporaryDirectory();
const directory = path.join(root, "saved");
const statePath = path.join(root, "state", "save-location.json");
await mkdir(directory);
await saveSaveLocationState(statePath, directory);
await expect(loadSaveLocationState(statePath)).resolves.toEqual({
version: 1,
directory
});
expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual({
version: 1,
directory
});
});
it("跨 Store 实例恢复最近成功文件目录", async () => {
const root = await createTemporaryDirectory();
const savedDirectory = path.join(root, "saved");
const documentsDirectory = path.join(root, "documents");
const statePath = path.join(root, "save-location.json");
await Promise.all([
mkdir(savedDirectory),
mkdir(documentsDirectory)
]);
const first = new SaveLocationStore(
statePath,
documentsDirectory
);
await first.initialize();
await first.recordSuccessfulFile(
path.join(savedDirectory, "first.pdf")
);
const restored = new SaveLocationStore(
statePath,
documentsDirectory
);
await restored.initialize();
await expect(
restored.resolveDefaultPath(undefined, "second.docx")
).resolves.toBe(path.join(savedDirectory, "second.docx"));
});
});