Fix/open app (#658)

* fix: registry-js not copied

* Update wiki

* Update getWorkspaceMenuTemplate.ts

* fix: tiddlers\新条目.tid become "tiddlers/\346\226\260\346\235\241\347\233\256.tid" in git log

* fix: git can't show and discard newly added or deleted files

* refactor: duplicate code

* lint

* fix: type
This commit is contained in:
lin onetwo 2025-11-24 03:42:17 +08:00 committed by GitHub
parent 45e3f76da1
commit a674cd269f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 386 additions and 79 deletions

View file

@ -251,18 +251,23 @@
"CopyFilePath": "Copy file path", "CopyFilePath": "Copy file path",
"CopyHash": "Copy submission hash", "CopyHash": "Copy submission hash",
"CopyRelativeFilePath": "Copy relative file path", "CopyRelativeFilePath": "Copy relative file path",
"CopySuccess": "Copied to clipboard",
"CurrentVersion": "current version", "CurrentVersion": "current version",
"Date": "date", "Date": "date",
"Details": "Details", "Details": "Details",
"DiffView": "Difference Comparison", "DiffView": "Difference Comparison",
"DiscardChanges": "Discard changes", "DiscardChanges": "Discard changes",
"DiscardFailed": "Failed to discard changes",
"DiscardSuccess": "Changes discarded",
"FailedToLoadDiff": "Failed to load differences", "FailedToLoadDiff": "Failed to load differences",
"Files": "a file", "Files": "a file",
"FilesChanged": "{{count}} file changed", "FilesChanged": "{{count}} file changed",
"FilesChanged_other": "{{count}} files changed", "FilesChanged_other": "{{count}} files changed",
"Hash": "hash value", "Hash": "hash value",
"IgnoreExtension": "Ignore all .{{ext}} files", "IgnoreExtension": "Ignore all .{{ext}} files",
"IgnoreFailed": "Failed to add to .gitignore",
"IgnoreFile": "Ignore file (add to .gitignore)", "IgnoreFile": "Ignore file (add to .gitignore)",
"IgnoreSuccess": "Added to .gitignore",
"ImageInCommit": "Image at this commit", "ImageInCommit": "Image at this commit",
"ImageNotAvailable": "Image not available", "ImageNotAvailable": "Image not available",
"LoadingFull": "Loading...", "LoadingFull": "Loading...",

View file

@ -251,18 +251,23 @@
"CopyFilePath": "copier le chemin du fichier", "CopyFilePath": "copier le chemin du fichier",
"CopyHash": "copier soumettre le hachage", "CopyHash": "copier soumettre le hachage",
"CopyRelativeFilePath": "copier le chemin relatif", "CopyRelativeFilePath": "copier le chemin relatif",
"CopySuccess": "copie réussie",
"CurrentVersion": "version actuelle", "CurrentVersion": "version actuelle",
"Date": "date", "Date": "date",
"Details": "détails", "Details": "détails",
"DiffView": "comparaison des différences", "DiffView": "comparaison des différences",
"DiscardChanges": "abandonner les modifications", "DiscardChanges": "abandonner les modifications",
"DiscardFailed": "abandonner la modification échouée",
"DiscardSuccess": "Modification abandonnée",
"FailedToLoadDiff": "Échec du chargement des différences", "FailedToLoadDiff": "Échec du chargement des différences",
"Files": "fichier", "Files": "fichier",
"FilesChanged": "fichiers modifiés", "FilesChanged": "fichiers modifiés",
"FilesChanged_other": "{{count}} fichiers modifiés", "FilesChanged_other": "{{count}} fichiers modifiés",
"Hash": "valeur de hachage", "Hash": "valeur de hachage",
"IgnoreExtension": "Ignorer tous les fichiers .{{ext}}", "IgnoreExtension": "Ignorer tous les fichiers .{{ext}}",
"IgnoreFailed": "Échec de l'ajout au .gitignore",
"IgnoreFile": "Ignorer les fichiers (ajouter à .gitignore)", "IgnoreFile": "Ignorer les fichiers (ajouter à .gitignore)",
"IgnoreSuccess": "Ajouté à .gitignore",
"ImageInCommit": "Image à ce commit", "ImageInCommit": "Image à ce commit",
"ImageNotAvailable": "Image non disponible", "ImageNotAvailable": "Image non disponible",
"LoadingFull": "Chargement en cours...", "LoadingFull": "Chargement en cours...",

View file

@ -251,18 +251,23 @@
"CopyFilePath": "ファイルパスをコピー", "CopyFilePath": "ファイルパスをコピー",
"CopyHash": "コピーしてハッシュを提出", "CopyHash": "コピーしてハッシュを提出",
"CopyRelativeFilePath": "相対パスをコピー", "CopyRelativeFilePath": "相対パスをコピー",
"CopySuccess": "コピー成功",
"CurrentVersion": "現在のバージョン", "CurrentVersion": "現在のバージョン",
"Date": "日付", "Date": "日付",
"Details": "詳細", "Details": "詳細",
"DiffView": "差異比較", "DiffView": "差異比較",
"DiscardChanges": "修正を放棄する", "DiscardChanges": "修正を放棄する",
"DiscardFailed": "修正の放棄に失敗しました",
"DiscardSuccess": "修正を放棄しました",
"FailedToLoadDiff": "差分の読み込みに失敗しました", "FailedToLoadDiff": "差分の読み込みに失敗しました",
"Files": "ファイル", "Files": "ファイル",
"FilesChanged": "変更されたファイル", "FilesChanged": "変更されたファイル",
"FilesChanged_other": "{{count}} 個のファイルに変更があります", "FilesChanged_other": "{{count}} 個のファイルに変更があります",
"Hash": "ハッシュ値", "Hash": "ハッシュ値",
"IgnoreExtension": "すべての .{{ext}} ファイルを無視する", "IgnoreExtension": "すべての .{{ext}} ファイルを無視する",
"IgnoreFailed": ".gitignore への追加に失敗しました",
"IgnoreFile": "ファイルを無視(.gitignoreに追加", "IgnoreFile": "ファイルを無視(.gitignoreに追加",
"IgnoreSuccess": ".gitignoreに追加されました",
"ImageInCommit": "このコミット時の画像", "ImageInCommit": "このコミット時の画像",
"ImageNotAvailable": "画像が利用できません", "ImageNotAvailable": "画像が利用できません",
"LoadingFull": "読み込み中...", "LoadingFull": "読み込み中...",

View file

@ -251,18 +251,23 @@
"CopyFilePath": "скопировать путь к файлу", "CopyFilePath": "скопировать путь к файлу",
"CopyHash": "скопировать хэш отправки", "CopyHash": "скопировать хэш отправки",
"CopyRelativeFilePath": "скопировать относительный путь", "CopyRelativeFilePath": "скопировать относительный путь",
"CopySuccess": "Копирование успешно",
"CurrentVersion": "текущая версия", "CurrentVersion": "текущая версия",
"Date": "дата", "Date": "дата",
"Details": "подробности", "Details": "подробности",
"DiffView": "сравнение различий", "DiffView": "сравнение различий",
"DiscardChanges": "отказаться от изменений", "DiscardChanges": "отказаться от изменений",
"DiscardFailed": "Отказаться от изменения неудачи",
"DiscardSuccess": "Отказался от изменений",
"FailedToLoadDiff": "Не удалось загрузить различия", "FailedToLoadDiff": "Не удалось загрузить различия",
"Files": "файл", "Files": "файл",
"FilesChanged": "измененные файлы", "FilesChanged": "измененные файлы",
"FilesChanged_other": "{{count}} файлов изменено", "FilesChanged_other": "{{count}} файлов изменено",
"Hash": "хэш-значение", "Hash": "хэш-значение",
"IgnoreExtension": "Игнорировать все файлы .{{ext}}", "IgnoreExtension": "Игнорировать все файлы .{{ext}}",
"IgnoreFailed": "Не удалось добавить в .gitignore",
"IgnoreFile": "Игнорировать файлы (добавить в .gitignore)", "IgnoreFile": "Игнорировать файлы (добавить в .gitignore)",
"IgnoreSuccess": "добавлено в .gitignore",
"ImageInCommit": "Изображение в этом коммите", "ImageInCommit": "Изображение в этом коммите",
"ImageNotAvailable": "Изображение недоступно", "ImageNotAvailable": "Изображение недоступно",
"LoadingFull": "Загрузка...", "LoadingFull": "Загрузка...",

View file

@ -251,18 +251,23 @@
"CopyFilePath": "复制文件路径", "CopyFilePath": "复制文件路径",
"CopyHash": "复制提交哈希", "CopyHash": "复制提交哈希",
"CopyRelativeFilePath": "复制相对路径", "CopyRelativeFilePath": "复制相对路径",
"CopySuccess": "复制成功",
"CurrentVersion": "当前版本", "CurrentVersion": "当前版本",
"Date": "日期", "Date": "日期",
"Details": "详情", "Details": "详情",
"DiffView": "差异对比", "DiffView": "差异对比",
"DiscardChanges": "放弃修改", "DiscardChanges": "放弃修改",
"DiscardFailed": "放弃修改失败",
"DiscardSuccess": "已放弃修改",
"FailedToLoadDiff": "加载差异失败", "FailedToLoadDiff": "加载差异失败",
"Files": "文件", "Files": "文件",
"FilesChanged": "变更了 {{count}} 个文件", "FilesChanged": "变更了 {{count}} 个文件",
"FilesChanged_other": "{{count}} 个文件有改动", "FilesChanged_other": "{{count}} 个文件有改动",
"Hash": "哈希值", "Hash": "哈希值",
"IgnoreExtension": "忽略所有 .{{ext}} 文件", "IgnoreExtension": "忽略所有 .{{ext}} 文件",
"IgnoreFailed": "添加到 .gitignore 失败",
"IgnoreFile": "忽略文件(添加到 .gitignore", "IgnoreFile": "忽略文件(添加到 .gitignore",
"IgnoreSuccess": "已添加到 .gitignore",
"ImageInCommit": "此提交时的图片", "ImageInCommit": "此提交时的图片",
"ImageNotAvailable": "图片不可用", "ImageNotAvailable": "图片不可用",
"LoadingFull": "加载中...", "LoadingFull": "加载中...",

View file

@ -251,18 +251,23 @@
"CopyFilePath": "複製檔案路徑", "CopyFilePath": "複製檔案路徑",
"CopyHash": "複製提交哈希", "CopyHash": "複製提交哈希",
"CopyRelativeFilePath": "複製相對路徑", "CopyRelativeFilePath": "複製相對路徑",
"CopySuccess": "複製成功",
"CurrentVersion": "目前版本", "CurrentVersion": "目前版本",
"Date": "日期", "Date": "日期",
"Details": "詳情", "Details": "詳情",
"DiffView": "差異對比", "DiffView": "差異對比",
"DiscardChanges": "放棄修改", "DiscardChanges": "放棄修改",
"DiscardFailed": "放棄修改失敗",
"DiscardSuccess": "已放棄修改",
"FailedToLoadDiff": "載入差異失敗", "FailedToLoadDiff": "載入差異失敗",
"Files": "檔案", "Files": "檔案",
"FilesChanged": "變更了 {{count}} 個文件", "FilesChanged": "變更了 {{count}} 個文件",
"FilesChanged_other": "{{count}} 個檔案有變更", "FilesChanged_other": "{{count}} 個檔案有變更",
"Hash": "雜湊值", "Hash": "雜湊值",
"IgnoreExtension": "忽略所有 .{{ext}} 檔案", "IgnoreExtension": "忽略所有 .{{ext}} 檔案",
"IgnoreFailed": "添加到 .gitignore 失敗",
"IgnoreFile": "忽略檔案(新增至 .gitignore", "IgnoreFile": "忽略檔案(新增至 .gitignore",
"IgnoreSuccess": "已添加到 .gitignore",
"ImageInCommit": "此提交時的圖片", "ImageInCommit": "此提交時的圖片",
"ImageNotAvailable": "圖片不可用", "ImageNotAvailable": "圖片不可用",
"LoadingFull": "載入中...", "LoadingFull": "載入中...",

View file

@ -18,7 +18,7 @@ import path from 'path';
export default ( export default (
buildPath: string, buildPath: string,
_electronVersion: string, _electronVersion: string,
_platform: string, platform: string,
_arch: string, _arch: string,
callback: () => void, callback: () => void,
): void => { ): void => {
@ -46,8 +46,6 @@ export default (
['tiddlywiki', 'plugins', 'tiddlywiki', 'filesystem'], ['tiddlywiki', 'plugins', 'tiddlywiki', 'filesystem'],
['tiddlywiki', 'plugins', 'tiddlywiki', 'tiddlyweb'], ['tiddlywiki', 'plugins', 'tiddlywiki', 'tiddlyweb'],
['tiddlywiki', 'tiddlywiki.js'], ['tiddlywiki', 'tiddlywiki.js'],
// we only need its `main` binary, no need its dependency and code, because we already copy it to src/services/native/externalApp
['app-path', 'main'],
// node binary // node binary
['better-sqlite3', 'build', 'Release', 'better_sqlite3.node'], ['better-sqlite3', 'build', 'Release', 'better_sqlite3.node'],
// nsfw native module // nsfw native module
@ -59,6 +57,11 @@ export default (
[`sqlite-vec-${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`], [`sqlite-vec-${process.platform === 'win32' ? 'windows' : process.platform}-${process.arch}`],
]; ];
// macOS only: copy app-path binary for finding apps
if (platform === 'darwin') {
packagePathsToCopyDereferenced.push(['app-path', 'main']);
}
console.log('Copying packagePathsToCopyDereferenced'); console.log('Copying packagePathsToCopyDereferenced');
for (const packagePathInNodeModules of packagePathsToCopyDereferenced) { for (const packagePathInNodeModules of packagePathsToCopyDereferenced) {
// some binary may not exist in other platforms, so allow failing here. // some binary may not exist in other platforms, so allow failing here.
@ -84,6 +87,16 @@ export default (
path.join(cwd, 'node_modules', 'dugite'), path.join(cwd, 'node_modules', 'dugite'),
{ dereference: false }, { dereference: false },
); );
if (platform === 'win32') {
console.log('Copy registry-js (Windows only)');
// registry-js has native binary that is loaded using relative path (../../build/Release/registry.node)
fs.copySync(
path.join(sourceNodeModulesFolder, 'registry-js'),
path.join(cwd, 'node_modules', 'registry-js'),
{ dereference: true },
);
}
} }
/** complete this hook */ /** complete this hook */

View file

@ -7,7 +7,7 @@ import { exec as gitExec } from 'dugite';
import * as fs from 'node:fs/promises'; import * as fs from 'node:fs/promises';
import * as path from 'node:path'; import * as path from 'node:path';
import { defaultGitInfo } from './defaultGitInfo'; import { defaultGitInfo } from './defaultGitInfo';
import type { IGitLogOptions, IGitLogResult } from './interface'; import type { GitFileStatus, IFileDiffResult, IGitLogOptions, IGitLogResult } from './interface';
/** /**
* Helper to create git environment variables for commit operations * Helper to create git environment variables for commit operations
@ -31,7 +31,7 @@ export async function getGitLog(repoPath: string, options: IGitLogOptions = {}):
const skip = page * pageSize; const skip = page * pageSize;
// Check for uncommitted changes // Check for uncommitted changes
const statusResult = await gitExec(['status', '--porcelain'], repoPath); const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain'], repoPath);
const hasUncommittedChanges = statusResult.stdout.trim().length > 0; const hasUncommittedChanges = statusResult.stdout.trim().length > 0;
// Build git log command arguments // Build git log command arguments
@ -120,14 +120,45 @@ export async function getGitLog(repoPath: string, options: IGitLogOptions = {}):
}; };
} }
/**
* Parse git status code to file status
* Handles both git status --porcelain (two-character codes like "M ", " D", "??")
* and git diff-tree --name-status (single-character codes like "M", "D", "A")
*/
function parseGitStatusCode(statusCode: string): GitFileStatus {
// Handle single-character status codes from diff-tree
if (statusCode.length === 1) {
if (statusCode === 'A') return 'added';
if (statusCode === 'M') return 'modified';
if (statusCode === 'D') return 'deleted';
if (statusCode.startsWith('R')) return 'renamed';
if (statusCode.startsWith('C')) return 'copied';
return 'unknown';
}
// Handle two-character status codes from git status --porcelain
const index = statusCode[0];
const workTree = statusCode[1];
// Check for specific patterns
if (statusCode === '??') return 'untracked';
if (index === 'A' || workTree === 'A') return 'added';
if (index === 'D' || workTree === 'D') return 'deleted';
if (index === 'R' || workTree === 'R') return 'renamed';
if (index === 'C' || workTree === 'C') return 'copied';
if (index === 'M' || workTree === 'M') return 'modified';
return 'unknown';
}
/** /**
* Get files changed in a specific commit * Get files changed in a specific commit
* If commitHash is empty, returns uncommitted changes * If commitHash is empty, returns uncommitted changes
*/ */
export async function getCommitFiles(repoPath: string, commitHash: string): Promise<string[]> { export async function getCommitFiles(repoPath: string, commitHash: string): Promise<Array<import('./interface').IFileWithStatus>> {
// Handle uncommitted changes // Handle uncommitted changes
if (!commitHash || commitHash === '') { if (!commitHash || commitHash === '') {
const result = await gitExec(['status', '--porcelain'], repoPath); const result = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain'], repoPath);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error(`Failed to get uncommitted files: ${result.stderr}`); throw new Error(`Failed to get uncommitted files: ${result.stderr}`);
@ -139,22 +170,29 @@ export async function getCommitFiles(repoPath: string, commitHash: string): Prom
.filter((line: string) => line.length > 0) .filter((line: string) => line.length > 0)
.map((line: string) => { .map((line: string) => {
if (line.length <= 3) { if (line.length <= 3) {
return line.trim(); return { path: line.trim(), status: 'unknown' as const };
} }
// Parse git status format: "XY filename" // Parse git status format: "XY filename"
// XY is two-letter status code, filename starts at position 3 // XY is two-letter status code, filename starts at position 3
const statusCode = line.slice(0, 2);
const rawPath = line.slice(3); const rawPath = line.slice(3);
// Handle rename format: "old -> new" we want the new path // Handle rename format: "old -> new" we want the new path
const renameParts = rawPath.split(' -> '); const renameParts = rawPath.split(' -> ');
return renameParts[renameParts.length - 1].trim(); const filePath = renameParts[renameParts.length - 1].trim();
return {
path: filePath,
status: parseGitStatusCode(statusCode),
};
}) })
.filter((line: string) => line.length > 0); .filter((item) => item.path.length > 0);
} }
// For committed changes, use diff-tree with --name-status to get file status
const result = await gitExec( const result = await gitExec(
['diff-tree', '--no-commit-id', '--name-only', '-r', commitHash], ['-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-status', '-r', commitHash],
repoPath, repoPath,
); );
@ -165,7 +203,15 @@ export async function getCommitFiles(repoPath: string, commitHash: string): Prom
return result.stdout return result.stdout
.trim() .trim()
.split('\n') .split('\n')
.filter((line: string) => line.length > 0); .filter((line: string) => line.length > 0)
.map((line: string) => {
// Format: "STATUS\tFILENAME" or "STATUS\tOLDNAME\tNEWNAME" for renames
const parts = line.split('\t');
const statusChar = parts[0];
const filePath = parts[parts.length - 1]; // Use last part for renames
return { path: filePath, status: parseGitStatusCode(statusChar) };
});
} }
/** /**
@ -183,7 +229,7 @@ export async function getFileDiff(
maxChars = 10000, maxChars = 10000,
): Promise<import('./interface').IFileDiffResult> { ): Promise<import('./interface').IFileDiffResult> {
if (!commitHash) { if (!commitHash) {
const statusResult = await gitExec(['status', '--porcelain', '--', filePath], repoPath); const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain', '--', filePath], repoPath);
if (statusResult.exitCode !== 0) { if (statusResult.exitCode !== 0) {
throw new Error(`Failed to get status for working tree diff: ${statusResult.stderr}`); throw new Error(`Failed to get status for working tree diff: ${statusResult.stderr}`);
@ -221,6 +267,40 @@ export async function getFileDiff(
} }
} }
// Check if file is deleted
const isDeleted = statusCode.includes('D');
if (isDeleted) {
// For deleted files, show the deletion diff
const result = await gitExec(
['diff', 'HEAD', '--', filePath],
repoPath,
);
if (result.exitCode !== 0) {
// If diff fails, try to show the file content from HEAD
const headContent = await gitExec(
['show', `HEAD:${filePath}`],
repoPath,
);
if (headContent.exitCode === 0) {
const diff = [
`diff --git a/${filePath} b/${filePath}`,
'deleted file mode 100644',
`--- a/${filePath}`,
'+++ /dev/null',
...headContent.stdout.split(/\r?\n/).map(line => `-${line}`),
].join('\n');
return truncateDiff(diff, maxLines, maxChars);
}
throw new Error(`Failed to get diff for deleted file: ${result.stderr}`);
}
return truncateDiff(result.stdout, maxLines, maxChars);
}
const result = await gitExec( const result = await gitExec(
['diff', 'HEAD', '--', filePath], ['diff', 'HEAD', '--', filePath],
repoPath, repoPath,
@ -272,10 +352,26 @@ export async function getFileContent(
const absolutePath = path.join(repoPath, filePath); const absolutePath = path.join(repoPath, filePath);
try { try {
// Try to read the file directly
const content = await fs.readFile(absolutePath, 'utf-8'); const content = await fs.readFile(absolutePath, 'utf-8');
return truncateContent(content, maxLines, maxChars); return truncateContent(content, maxLines, maxChars);
} catch (error) { } catch (error) {
throw new Error(`Failed to read working tree file: ${error instanceof Error ? error.message : String(error)}`); // File doesn't exist or can't be read - it might be deleted
// Try to get from HEAD
try {
const result = await gitExec(
['show', `HEAD:${filePath}`],
repoPath,
);
if (result.exitCode === 0) {
return truncateContent(result.stdout, maxLines, maxChars);
}
} catch {
// Silently fail and throw main error
}
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read file: ${errorMessage}`);
} }
} }
@ -412,7 +508,7 @@ function createBinaryDiffPlaceholder(filePath: string): string {
* Truncate diff output if it exceeds the limits * Truncate diff output if it exceeds the limits
*/ */
function truncateDiff(diff: string, maxLines: number, maxChars: number): import('./interface').IFileDiffResult { function truncateDiff(diff: string, maxLines: number, maxChars: number): IFileDiffResult {
let truncated = diff; let truncated = diff;
let isTruncated = false; let isTruncated = false;
@ -436,9 +532,9 @@ function truncateDiff(diff: string, maxLines: number, maxChars: number): import(
} }
/** /**
* Truncate content if it exceeds the limits * Truncate content output if it exceeds the limits
*/ */
function truncateContent(content: string, maxLines: number, maxChars: number): import('./interface').IFileDiffResult { function truncateContent(content: string, maxLines: number, maxChars: number): IFileDiffResult {
return truncateDiff(content, maxLines, maxChars); return truncateDiff(content, maxLines, maxChars);
} }
@ -484,13 +580,43 @@ export async function revertCommit(repoPath: string, commitHash: string, commitM
} }
/** /**
* Discard changes for a specific file (restore from HEAD) * Discard changes for a specific file (restore from HEAD or delete if untracked)
*/ */
export async function discardFileChanges(repoPath: string, filePath: string): Promise<void> { export async function discardFileChanges(repoPath: string, filePath: string): Promise<void> {
const result = await gitExec(['checkout', 'HEAD', '--', filePath], repoPath); // First check the file status to determine if it's untracked (new file)
const statusResult = await gitExec(['-c', 'core.quotePath=false', 'status', '--porcelain', '--', filePath], repoPath);
if (result.exitCode !== 0) { if (statusResult.exitCode !== 0) {
throw new Error(`Failed to discard changes: ${result.stderr}`); throw new Error(`Failed to check file status: ${statusResult.stderr}`);
}
const statusLine = statusResult.stdout.trim();
// If empty, file is not modified
if (!statusLine) {
return;
}
// Parse status code (first two characters)
const statusCode = statusLine.slice(0, 2);
// Check if file is untracked (new file not yet added)
// Status code "??" means untracked
if (statusCode === '??') {
// For untracked files, we need to delete them
const fullPath = path.join(repoPath, filePath);
try {
await fs.unlink(fullPath);
} catch (error) {
throw new Error(`Failed to delete untracked file: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
// For tracked files, use git checkout to restore from HEAD
const result = await gitExec(['checkout', 'HEAD', '--', filePath], repoPath);
if (result.exitCode !== 0) {
throw new Error(`Failed to discard changes: ${result.stderr}`);
}
} }
} }
@ -555,7 +681,7 @@ export async function getDeletedTiddlersSinceDate(repoPath: string, sinceDate: D
// Get list of deleted files since sinceDate // Get list of deleted files since sinceDate
// Using git log with --diff-filter=D to show only deletions // Using git log with --diff-filter=D to show only deletions
const logResult = await gitExec( const logResult = await gitExec(
['log', `--since=${sinceISOString}`, '--diff-filter=D', '--name-only', '--pretty=format:'], ['-c', 'core.quotePath=false', 'log', `--since=${sinceISOString}`, '--diff-filter=D', '--name-only', '--pretty=format:'],
repoPath, repoPath,
); );
@ -636,7 +762,7 @@ export async function getTiddlerAtTime(
// First, find all .tid and .meta files that might contain this tiddler // First, find all .tid and .meta files that might contain this tiddler
// We need to search for files because the title might not match the filename // We need to search for files because the title might not match the filename
const logResult = await gitExec( const logResult = await gitExec(
['log', `--before=${beforeISOString}`, '--name-only', '--pretty=format:%H', '--', '*.tid', '*.meta'], ['-c', 'core.quotePath=false', 'log', `--before=${beforeISOString}`, '--name-only', '--pretty=format:%H', '--', '*.tid', '*.meta'],
repoPath, repoPath,
); );

View file

@ -24,7 +24,18 @@ import { WindowNames } from '@services/windows/WindowProperties';
import { isWikiWorkspace, type IWorkspace } from '@services/workspaces/interface'; import { isWikiWorkspace, type IWorkspace } from '@services/workspaces/interface';
import * as gitOperations from './gitOperations'; import * as gitOperations from './gitOperations';
import type { GitWorker } from './gitWorker'; import type { GitWorker } from './gitWorker';
import type { ICommitAndSyncConfigs, IForcePullConfigs, IGitLogMessage, IGitLogOptions, IGitLogResult, IGitService, IGitStateChange, IGitUserInfos } from './interface'; import type {
ICommitAndSyncConfigs,
IFileDiffResult,
IFileWithStatus,
IForcePullConfigs,
IGitLogMessage,
IGitLogOptions,
IGitLogResult,
IGitService,
IGitStateChange,
IGitUserInfos,
} from './interface';
import { registerMenu } from './registerMenu'; import { registerMenu } from './registerMenu';
import { getErrorMessageI18NDict, translateMessage } from './translateMessage'; import { getErrorMessageI18NDict, translateMessage } from './translateMessage';
@ -359,15 +370,15 @@ export class Git implements IGitService {
return await gitOperations.getGitLog(wikiFolderPath, options); return await gitOperations.getGitLog(wikiFolderPath, options);
} }
public async getCommitFiles(wikiFolderPath: string, commitHash: string): Promise<string[]> { public async getCommitFiles(wikiFolderPath: string, commitHash: string): Promise<IFileWithStatus[]> {
return await gitOperations.getCommitFiles(wikiFolderPath, commitHash); return await gitOperations.getCommitFiles(wikiFolderPath, commitHash);
} }
public async getFileDiff(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise<import('./interface').IFileDiffResult> { public async getFileDiff(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise<IFileDiffResult> {
return await gitOperations.getFileDiff(wikiFolderPath, commitHash, filePath, maxLines, maxChars); return await gitOperations.getFileDiff(wikiFolderPath, commitHash, filePath, maxLines, maxChars);
} }
public async getFileContent(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise<import('./interface').IFileDiffResult> { public async getFileContent(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise<IFileDiffResult> {
return await gitOperations.getFileContent(wikiFolderPath, commitHash, filePath, maxLines, maxChars); return await gitOperations.getFileContent(wikiFolderPath, commitHash, filePath, maxLines, maxChars);
} }
@ -408,6 +419,8 @@ export class Git implements IGitService {
public async addToGitignore(wikiFolderPath: string, pattern: string): Promise<void> { public async addToGitignore(wikiFolderPath: string, pattern: string): Promise<void> {
await gitOperations.addToGitignore(wikiFolderPath, pattern); await gitOperations.addToGitignore(wikiFolderPath, pattern);
// Notify git state change to refresh git log
this.notifyGitStateChange(wikiFolderPath, 'file-change');
} }
public async isAIGenerateBackupTitleEnabled(): Promise<boolean> { public async isAIGenerateBackupTitleEnabled(): Promise<boolean> {

View file

@ -65,6 +65,13 @@ export interface IFileDiffResult {
isTruncated: boolean; isTruncated: boolean;
} }
export type GitFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'untracked' | 'unknown';
export interface IFileWithStatus {
path: string;
status: GitFileStatus;
}
/** /**
* Git state change event * Git state change event
*/ */
@ -117,7 +124,7 @@ export interface IGitService {
/** /**
* Get files changed in a specific commit * Get files changed in a specific commit
*/ */
getCommitFiles(wikiFolderPath: string, commitHash: string): Promise<string[]>; getCommitFiles(wikiFolderPath: string, commitHash: string): Promise<IFileWithStatus[]>;
/** /**
* Get the diff for a specific file in a commit * Get the diff for a specific file in a commit
* @param maxLines - Maximum number of lines to return (default: 500) * @param maxLines - Maximum number of lines to return (default: 500)

View file

@ -62,31 +62,14 @@ export async function getSimplifiedWorkspaceMenuTemplate(
const { id, storageService, isSubWiki } = workspace; const { id, storageService, isSubWiki } = workspace;
const template: MenuItemConstructorOptions[] = []; const template: MenuItemConstructorOptions[] = [];
// Add command palette first // Add "Current Workspace" submenu with full menu
template.push({ const fullMenuTemplate = await getWorkspaceMenuTemplate(workspace, t, service);
label: t('ContextMenu.OpenCommandPalette'), if (fullMenuTemplate.length > 0) {
click: async () => { template.push({
await service.wiki.wikiOperationInBrowser(WikiChannel.dispatchEvent, id, ['open-command-palette']); label: t('Menu.CurrentWorkspace'),
}, submenu: fullMenuTemplate,
}); });
// Edit workspace
template.push({
label: t('WorkspaceSelector.EditWorkspace'),
click: async () => {
await service.window.open(WindowNames.editWorkspace, { workspaceID: id });
},
});
// Check if AI-generated backup title is enabled
const aiGenerateBackupTitleEnabled = await service.git.isAIGenerateBackupTitleEnabled();
// Backup/Sync options (based on storage service)
if (storageService === SupportedStorageServices.local) {
const backupItems = createBackupMenuItems(workspace, t, service.window, service.git, aiGenerateBackupTitleEnabled, false);
template.push(...backupItems);
} }
// Restart and Reload (only for non-sub wikis) // Restart and Reload (only for non-sub wikis)
if (!isSubWiki) { if (!isSubWiki) {
template.push( template.push(
@ -103,17 +86,29 @@ export async function getSimplifiedWorkspaceMenuTemplate(
await service.view.reloadViewsWebContents(id); await service.view.reloadViewsWebContents(id);
}, },
}, },
{
label: t('ContextMenu.OpenCommandPalette'),
click: async () => {
await service.wiki.wikiOperationInBrowser(WikiChannel.dispatchEvent, id, ['open-command-palette']);
},
},
); );
} }
// Edit workspace
template.push({
label: t('WorkspaceSelector.EditWorkspace'),
click: async () => {
await service.window.open(WindowNames.editWorkspace, { workspaceID: id });
},
});
// Add "Current Workspace" submenu with full menu // Check if AI-generated backup title is enabled
const fullMenuTemplate = await getWorkspaceMenuTemplate(workspace, t, service); const aiGenerateBackupTitleEnabled = await service.git.isAIGenerateBackupTitleEnabled();
if (fullMenuTemplate.length > 0) {
template.push({ type: 'separator' }); // Backup/Sync options (based on storage service)
template.push({ if (storageService === SupportedStorageServices.local) {
label: t('Menu.CurrentWorkspace'), const backupItems = createBackupMenuItems(workspace, t, service.window, service.git, aiGenerateBackupTitleEnabled, false);
submenu: fullMenuTemplate, template.push(...backupItems);
});
} }
return template; return template;

View file

@ -13,6 +13,7 @@ import Typography from '@mui/material/Typography';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getFileStatusStyles, type GitFileStatus } from './fileStatusStyles';
import type { GitLogEntry } from './types'; import type { GitLogEntry } from './types';
const Panel = styled(Box)` const Panel = styled(Box)`
@ -53,6 +54,17 @@ const ActionsWrapper = styled(Box)`
gap: 12px; gap: 12px;
`; `;
const FileStatusBadge = styled(Box)<{ $status?: GitFileStatus }>`
display: inline-block;
font-size: 0.6rem;
padding: 1px 4px;
margin-right: 4px;
border-radius: 2px;
font-weight: 600;
text-transform: uppercase;
${({ $status, theme }) => getFileStatusStyles($status, theme)}
`;
interface ICommitDetailsPanelProps { interface ICommitDetailsPanelProps {
commit: GitLogEntry | null; commit: GitLogEntry | null;
isLatestCommit?: boolean; isLatestCommit?: boolean;
@ -241,13 +253,18 @@ export function CommitDetailsPanel(
{fileChanges.map((file, index) => ( {fileChanges.map((file, index) => (
<ListItem key={index} disablePadding> <ListItem key={index} disablePadding>
<ListItemButton <ListItemButton
selected={file === selectedFile} selected={file.path === selectedFile}
onClick={() => { onClick={() => {
onFileSelect?.(file === selectedFile ? null : file); onFileSelect?.(file.path === selectedFile ? null : file.path);
}} }}
> >
<ListItemText <ListItemText
primary={file} primary={
<>
<FileStatusBadge $status={file.status}>{file.status.charAt(0)}</FileStatusBadge>
{file.path}
</>
}
slotProps={{ slotProps={{
primary: { primary: {
variant: 'body2', variant: 'body2',

View file

@ -136,9 +136,10 @@ interface IFileDiffPanelProps {
commitHash: string; commitHash: string;
filePath: string | null; filePath: string | null;
onDiscardSuccess?: () => void; onDiscardSuccess?: () => void;
showSnackbar?: (message: string, severity: 'success' | 'error' | 'info') => void;
} }
export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileDiffPanelProps): React.JSX.Element { export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess, showSnackbar: showSnackbarFromParent }: IFileDiffPanelProps): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const [diff, setDiff] = useState<string>(''); const [diff, setDiff] = useState<string>('');
const [fileContent, setFileContent] = useState<string>(''); const [fileContent, setFileContent] = useState<string>('');
@ -153,6 +154,9 @@ export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileD
const [isLoadingFullDiff, setIsLoadingFullDiff] = useState(false); const [isLoadingFullDiff, setIsLoadingFullDiff] = useState(false);
const [isLoadingFullContent, setIsLoadingFullContent] = useState(false); const [isLoadingFullContent, setIsLoadingFullContent] = useState(false);
// Use parent's showSnackbar if provided, otherwise create local one
const showSnackbar = showSnackbarFromParent ?? (() => {});
const getWorkspace = async () => { const getWorkspace = async () => {
const meta = window.meta(); const meta = window.meta();
const workspaceID = (meta as { workspaceID?: string }).workspaceID; const workspaceID = (meta as { workspaceID?: string }).workspaceID;
@ -192,11 +196,13 @@ export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileD
try { try {
await window.service.git.discardFileChanges(workspace.wikiFolderLocation, filePath); await window.service.git.discardFileChanges(workspace.wikiFolderLocation, filePath);
showSnackbar(t('GitLog.DiscardSuccess'), 'success');
// Clear selection and trigger refresh // Clear selection and trigger refresh
onDiscardSuccess?.(); onDiscardSuccess?.();
} catch (error) { } catch (error) {
console.error('Failed to discard changes:', error); console.error('Failed to discard changes:', error);
// TODO: Show error message const errorMessage = error instanceof Error ? error.message : String(error);
showSnackbar(t('GitLog.DiscardFailed') + ': ' + errorMessage, 'error');
} }
}; };
@ -207,10 +213,13 @@ export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileD
try { try {
await window.service.git.addToGitignore(workspace.wikiFolderLocation, filePath); await window.service.git.addToGitignore(workspace.wikiFolderLocation, filePath);
// TODO: Show success message showSnackbar(t('GitLog.IgnoreSuccess'), 'success');
// Trigger refresh after adding to .gitignore
onDiscardSuccess?.();
} catch (error) { } catch (error) {
console.error('Failed to add to .gitignore:', error); console.error('Failed to add to .gitignore:', error);
// TODO: Show error message const errorMessage = error instanceof Error ? error.message : String(error);
showSnackbar(t('GitLog.IgnoreFailed') + ': ' + errorMessage, 'error');
} }
}; };
@ -221,10 +230,13 @@ export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileD
try { try {
await window.service.git.addToGitignore(workspace.wikiFolderLocation, `*.${fileExtension}`); await window.service.git.addToGitignore(workspace.wikiFolderLocation, `*.${fileExtension}`);
// TODO: Show success message showSnackbar(t('GitLog.IgnoreSuccess'), 'success');
// Trigger refresh after adding to .gitignore
onDiscardSuccess?.();
} catch (error) { } catch (error) {
console.error('Failed to add extension to .gitignore:', error); console.error('Failed to add extension to .gitignore:', error);
// TODO: Show error message const errorMessage = error instanceof Error ? error.message : String(error);
showSnackbar(t('GitLog.IgnoreFailed') + ': ' + errorMessage, 'error');
} }
}; };
@ -235,13 +247,13 @@ export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileD
const fullPath = `${workspace.wikiFolderLocation}/${filePath}`; const fullPath = `${workspace.wikiFolderLocation}/${filePath}`;
await navigator.clipboard.writeText(fullPath); await navigator.clipboard.writeText(fullPath);
// TODO: Show success message showSnackbar(t('GitLog.CopySuccess'), 'success');
}; };
const handleCopyRelativePath = async () => { const handleCopyRelativePath = async () => {
if (!filePath) return; if (!filePath) return;
await navigator.clipboard.writeText(filePath); await navigator.clipboard.writeText(filePath);
// TODO: Show success message showSnackbar(t('GitLog.CopySuccess'), 'success');
}; };
const handleShowInExplorer = async () => { const handleShowInExplorer = async () => {

View file

@ -0,0 +1,61 @@
import type { Theme } from '@mui/material/styles';
import type { GitFileStatus } from '../../services/git/interface';
// Re-export for convenience
export type { GitFileStatus };
/**
* Get styled CSS for file status badge/chip based on the status and theme
*/
export function getFileStatusStyles(status: GitFileStatus | undefined, theme: Theme): string {
const isDark = theme.palette.mode === 'dark';
switch (status) {
case 'added':
case 'untracked':
return isDark
? `
background-color: rgba(46, 160, 67, 0.3);
color: #7ee787;
`
: `
background-color: rgba(46, 160, 67, 0.2);
color: #116329;
`;
case 'deleted':
return isDark
? `
background-color: rgba(248, 81, 73, 0.3);
color: #ffa198;
`
: `
background-color: rgba(248, 81, 73, 0.2);
color: #82071e;
`;
case 'modified':
return isDark
? `
background-color: rgba(187, 128, 9, 0.3);
color: #f0b83f;
`
: `
background-color: rgba(187, 128, 9, 0.2);
color: #7d4e00;
`;
case 'renamed':
return isDark
? `
background-color: rgba(56, 139, 253, 0.3);
color: #79c0ff;
`
: `
background-color: rgba(56, 139, 253, 0.2);
color: #0969da;
`;
default:
return `
background-color: ${theme.palette.action.hover};
color: ${theme.palette.text.secondary};
`;
}
}

View file

@ -1,7 +1,9 @@
import { Helmet } from '@dr.pogodin/react-helmet'; import { Helmet } from '@dr.pogodin/react-helmet';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';
import Snackbar from '@mui/material/Snackbar';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import Tab from '@mui/material/Tab'; import Tab from '@mui/material/Tab';
import Table from '@mui/material/Table'; import Table from '@mui/material/Table';
@ -21,6 +23,7 @@ import { useTranslation } from 'react-i18next';
import { CommitDetailsPanel } from './CommitDetailsPanel'; import { CommitDetailsPanel } from './CommitDetailsPanel';
import { CustomGitTooltip } from './CustomGitTooltip'; import { CustomGitTooltip } from './CustomGitTooltip';
import { FileDiffPanel } from './FileDiffPanel'; import { FileDiffPanel } from './FileDiffPanel';
import { getFileStatusStyles, type GitFileStatus } from './fileStatusStyles';
import type { GitLogEntry } from './types'; import type { GitLogEntry } from './types';
import { useCommitDetails } from './useCommitDetails'; import { useCommitDetails } from './useCommitDetails';
import { useGitLogData } from './useGitLogData'; import { useGitLogData } from './useGitLogData';
@ -108,18 +111,18 @@ const LoadingContainer = styled(Box)`
height: 100%; height: 100%;
`; `;
const FileChip = styled(Box)` const FileChip = styled(Box)<{ $status?: GitFileStatus }>`
display: inline-block; display: inline-block;
font-size: 0.7rem; font-size: 0.7rem;
font-family: monospace; font-family: monospace;
padding: 2px 4px; padding: 2px 4px;
margin: 2px; margin: 2px;
background-color: ${({ theme }) => theme.palette.action.hover};
border-radius: 3px; border-radius: 3px;
max-width: 100px; max-width: 100px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
${({ $status, theme }) => getFileStatusStyles($status, theme)}
`; `;
interface ICommitTableRowProps { interface ICommitTableRowProps {
@ -158,10 +161,10 @@ function CommitTableRow({ commit, selected, commitDate, onSelect }: ICommitTable
<TableCell> <TableCell>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{displayFiles.map((file, index) => { {displayFiles.map((file, index) => {
const fileName = file.split('/').pop() || file; const fileName = file.path.split('/').pop() || file.path;
return ( return (
<Tooltip key={index} title={file} placement='top'> <Tooltip key={index} title={`${file.path} (${file.status})`} placement='top'>
<FileChip>{fileName}</FileChip> <FileChip $status={file.status}>{fileName}</FileChip>
</Tooltip> </Tooltip>
); );
})} })}
@ -195,6 +198,19 @@ export default function GitHistory(): React.JSX.Element {
const [selectedFile, setSelectedFile] = useState<string | null>(null); const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'current' | 'all'>('current'); const [viewMode, setViewMode] = useState<'current' | 'all'>('current');
const [shouldSelectFirst, setShouldSelectFirst] = useState(false); const [shouldSelectFirst, setShouldSelectFirst] = useState(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' | 'info' }>({
open: false,
message: '',
severity: 'info',
});
const showSnackbar = (message: string, severity: 'success' | 'error' | 'info' = 'info') => {
setSnackbar({ open: true, message, severity });
};
const handleCloseSnackbar = () => {
setSnackbar(previous => ({ ...previous, open: false }));
};
// Create a tooltip wrapper that passes the translation function // Create a tooltip wrapper that passes the translation function
// The props coming from react-git-log don't include 't', so we add it // The props coming from react-git-log don't include 't', so we add it
@ -387,9 +403,21 @@ export default function GitHistory(): React.JSX.Element {
// Trigger git log refresh after discard // Trigger git log refresh after discard
setShouldSelectFirst(true); setShouldSelectFirst(true);
}} }}
showSnackbar={showSnackbar}
/> />
</DiffPanelWrapper> </DiffPanelWrapper>
</ContentWrapper> </ContentWrapper>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert onClose={handleCloseSnackbar} severity={snackbar.severity} sx={{ width: '100%' }}>
{snackbar.message}
</Alert>
</Snackbar>
</Root> </Root>
); );
} }

View file

@ -1,3 +1,5 @@
import type { GitFileStatus, IFileWithStatus } from '../../services/git/interface';
/** /**
* Represents the author or committer of a commit. * Represents the author or committer of a commit.
*/ */
@ -12,6 +14,9 @@ export interface CommitAuthor {
name: string; name: string;
} }
// Re-export for convenience
export type { GitFileStatus, IFileWithStatus };
/** /**
* Represents a single entry in the git log. * Represents a single entry in the git log.
*/ */
@ -45,7 +50,7 @@ export interface GitLogEntry {
*/ */
parents: string[]; parents: string[];
/** /**
* Array of file paths changed in this commit. * Array of files with status changed in this commit.
*/ */
files?: string[]; files?: IFileWithStatus[];
} }

@ -1 +1 @@
Subproject commit 046be23ef8603996872c0acf2c012362d0cb394b Subproject commit 282e02bbcd825926db98fab6fcaf9c94cdb33783