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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ import path from 'path';
export default (
buildPath: string,
_electronVersion: string,
_platform: string,
platform: string,
_arch: string,
callback: () => void,
): void => {
@ -46,8 +46,6 @@ export default (
['tiddlywiki', 'plugins', 'tiddlywiki', 'filesystem'],
['tiddlywiki', 'plugins', 'tiddlywiki', 'tiddlyweb'],
['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
['better-sqlite3', 'build', 'Release', 'better_sqlite3.node'],
// nsfw native module
@ -59,6 +57,11 @@ export default (
[`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');
for (const packagePathInNodeModules of packagePathsToCopyDereferenced) {
// some binary may not exist in other platforms, so allow failing here.
@ -84,6 +87,16 @@ export default (
path.join(cwd, 'node_modules', 'dugite'),
{ 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 */

View file

@ -7,7 +7,7 @@ import { exec as gitExec } from 'dugite';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
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
@ -31,7 +31,7 @@ export async function getGitLog(repoPath: string, options: IGitLogOptions = {}):
const skip = page * pageSize;
// 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;
// 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
* 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
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) {
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)
.map((line: string) => {
if (line.length <= 3) {
return line.trim();
return { path: line.trim(), status: 'unknown' as const };
}
// Parse git status format: "XY filename"
// XY is two-letter status code, filename starts at position 3
const statusCode = line.slice(0, 2);
const rawPath = line.slice(3);
// Handle rename format: "old -> new" we want the new path
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(
['diff-tree', '--no-commit-id', '--name-only', '-r', commitHash],
['-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-status', '-r', commitHash],
repoPath,
);
@ -165,7 +203,15 @@ export async function getCommitFiles(repoPath: string, commitHash: string): Prom
return result.stdout
.trim()
.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,
): Promise<import('./interface').IFileDiffResult> {
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) {
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(
['diff', 'HEAD', '--', filePath],
repoPath,
@ -272,10 +352,26 @@ export async function getFileContent(
const absolutePath = path.join(repoPath, filePath);
try {
// Try to read the file directly
const content = await fs.readFile(absolutePath, 'utf-8');
return truncateContent(content, maxLines, maxChars);
} 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
*/
function truncateDiff(diff: string, maxLines: number, maxChars: number): import('./interface').IFileDiffResult {
function truncateDiff(diff: string, maxLines: number, maxChars: number): IFileDiffResult {
let truncated = diff;
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);
}
@ -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> {
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) {
throw new Error(`Failed to discard changes: ${result.stderr}`);
if (statusResult.exitCode !== 0) {
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
// Using git log with --diff-filter=D to show only deletions
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,
);
@ -636,7 +762,7 @@ export async function getTiddlerAtTime(
// 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
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,
);

View file

@ -24,7 +24,18 @@ import { WindowNames } from '@services/windows/WindowProperties';
import { isWikiWorkspace, type IWorkspace } from '@services/workspaces/interface';
import * as gitOperations from './gitOperations';
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 { getErrorMessageI18NDict, translateMessage } from './translateMessage';
@ -359,15 +370,15 @@ export class Git implements IGitService {
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);
}
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);
}
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);
}
@ -408,6 +419,8 @@ export class Git implements IGitService {
public async addToGitignore(wikiFolderPath: string, pattern: string): Promise<void> {
await gitOperations.addToGitignore(wikiFolderPath, pattern);
// Notify git state change to refresh git log
this.notifyGitStateChange(wikiFolderPath, 'file-change');
}
public async isAIGenerateBackupTitleEnabled(): Promise<boolean> {

View file

@ -65,6 +65,13 @@ export interface IFileDiffResult {
isTruncated: boolean;
}
export type GitFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'untracked' | 'unknown';
export interface IFileWithStatus {
path: string;
status: GitFileStatus;
}
/**
* Git state change event
*/
@ -117,7 +124,7 @@ export interface IGitService {
/**
* 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
* @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 template: MenuItemConstructorOptions[] = [];
// Add command palette first
template.push({
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 });
},
});
// 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);
// Add "Current Workspace" submenu with full menu
const fullMenuTemplate = await getWorkspaceMenuTemplate(workspace, t, service);
if (fullMenuTemplate.length > 0) {
template.push({
label: t('Menu.CurrentWorkspace'),
submenu: fullMenuTemplate,
});
}
// Restart and Reload (only for non-sub wikis)
if (!isSubWiki) {
template.push(
@ -103,17 +86,29 @@ export async function getSimplifiedWorkspaceMenuTemplate(
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
const fullMenuTemplate = await getWorkspaceMenuTemplate(workspace, t, service);
if (fullMenuTemplate.length > 0) {
template.push({ type: 'separator' });
template.push({
label: t('Menu.CurrentWorkspace'),
submenu: fullMenuTemplate,
});
// 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);
}
return template;

View file

@ -13,6 +13,7 @@ import Typography from '@mui/material/Typography';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getFileStatusStyles, type GitFileStatus } from './fileStatusStyles';
import type { GitLogEntry } from './types';
const Panel = styled(Box)`
@ -53,6 +54,17 @@ const ActionsWrapper = styled(Box)`
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 {
commit: GitLogEntry | null;
isLatestCommit?: boolean;
@ -241,13 +253,18 @@ export function CommitDetailsPanel(
{fileChanges.map((file, index) => (
<ListItem key={index} disablePadding>
<ListItemButton
selected={file === selectedFile}
selected={file.path === selectedFile}
onClick={() => {
onFileSelect?.(file === selectedFile ? null : file);
onFileSelect?.(file.path === selectedFile ? null : file.path);
}}
>
<ListItemText
primary={file}
primary={
<>
<FileStatusBadge $status={file.status}>{file.status.charAt(0)}</FileStatusBadge>
{file.path}
</>
}
slotProps={{
primary: {
variant: 'body2',

View file

@ -136,9 +136,10 @@ interface IFileDiffPanelProps {
commitHash: string;
filePath: string | null;
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 [diff, setDiff] = useState<string>('');
const [fileContent, setFileContent] = useState<string>('');
@ -153,6 +154,9 @@ export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileD
const [isLoadingFullDiff, setIsLoadingFullDiff] = useState(false);
const [isLoadingFullContent, setIsLoadingFullContent] = useState(false);
// Use parent's showSnackbar if provided, otherwise create local one
const showSnackbar = showSnackbarFromParent ?? (() => {});
const getWorkspace = async () => {
const meta = window.meta();
const workspaceID = (meta as { workspaceID?: string }).workspaceID;
@ -192,11 +196,13 @@ export function FileDiffPanel({ commitHash, filePath, onDiscardSuccess }: IFileD
try {
await window.service.git.discardFileChanges(workspace.wikiFolderLocation, filePath);
showSnackbar(t('GitLog.DiscardSuccess'), 'success');
// Clear selection and trigger refresh
onDiscardSuccess?.();
} catch (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 {
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) {
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 {
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) {
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}`;
await navigator.clipboard.writeText(fullPath);
// TODO: Show success message
showSnackbar(t('GitLog.CopySuccess'), 'success');
};
const handleCopyRelativePath = async () => {
if (!filePath) return;
await navigator.clipboard.writeText(filePath);
// TODO: Show success message
showSnackbar(t('GitLog.CopySuccess'), 'success');
};
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 Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Container from '@mui/material/Container';
import Snackbar from '@mui/material/Snackbar';
import { styled } from '@mui/material/styles';
import Tab from '@mui/material/Tab';
import Table from '@mui/material/Table';
@ -21,6 +23,7 @@ import { useTranslation } from 'react-i18next';
import { CommitDetailsPanel } from './CommitDetailsPanel';
import { CustomGitTooltip } from './CustomGitTooltip';
import { FileDiffPanel } from './FileDiffPanel';
import { getFileStatusStyles, type GitFileStatus } from './fileStatusStyles';
import type { GitLogEntry } from './types';
import { useCommitDetails } from './useCommitDetails';
import { useGitLogData } from './useGitLogData';
@ -108,18 +111,18 @@ const LoadingContainer = styled(Box)`
height: 100%;
`;
const FileChip = styled(Box)`
const FileChip = styled(Box)<{ $status?: GitFileStatus }>`
display: inline-block;
font-size: 0.7rem;
font-family: monospace;
padding: 2px 4px;
margin: 2px;
background-color: ${({ theme }) => theme.palette.action.hover};
border-radius: 3px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
${({ $status, theme }) => getFileStatusStyles($status, theme)}
`;
interface ICommitTableRowProps {
@ -158,10 +161,10 @@ function CommitTableRow({ commit, selected, commitDate, onSelect }: ICommitTable
<TableCell>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{displayFiles.map((file, index) => {
const fileName = file.split('/').pop() || file;
const fileName = file.path.split('/').pop() || file.path;
return (
<Tooltip key={index} title={file} placement='top'>
<FileChip>{fileName}</FileChip>
<Tooltip key={index} title={`${file.path} (${file.status})`} placement='top'>
<FileChip $status={file.status}>{fileName}</FileChip>
</Tooltip>
);
})}
@ -195,6 +198,19 @@ export default function GitHistory(): React.JSX.Element {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'current' | 'all'>('current');
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
// 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
setShouldSelectFirst(true);
}}
showSnackbar={showSnackbar}
/>
</DiffPanelWrapper>
</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>
);
}

View file

@ -1,3 +1,5 @@
import type { GitFileStatus, IFileWithStatus } from '../../services/git/interface';
/**
* Represents the author or committer of a commit.
*/
@ -12,6 +14,9 @@ export interface CommitAuthor {
name: string;
}
// Re-export for convenience
export type { GitFileStatus, IFileWithStatus };
/**
* Represents a single entry in the git log.
*/
@ -45,7 +50,7 @@ export interface GitLogEntry {
*/
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