feat: 优化桌面文件默认保存路径
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user