diff --git a/localization/locales/en/translation.json b/localization/locales/en/translation.json index 82594c85..bbe97c54 100644 --- a/localization/locales/en/translation.json +++ b/localization/locales/en/translation.json @@ -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...", diff --git a/localization/locales/fr/translation.json b/localization/locales/fr/translation.json index f8498096..476c6846 100644 --- a/localization/locales/fr/translation.json +++ b/localization/locales/fr/translation.json @@ -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...", diff --git a/localization/locales/ja/translation.json b/localization/locales/ja/translation.json index 2fc8954a..82c22038 100644 --- a/localization/locales/ja/translation.json +++ b/localization/locales/ja/translation.json @@ -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": "読み込み中...", diff --git a/localization/locales/ru/translation.json b/localization/locales/ru/translation.json index 4b33cfa4..0107f7df 100644 --- a/localization/locales/ru/translation.json +++ b/localization/locales/ru/translation.json @@ -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": "Загрузка...", diff --git a/localization/locales/zh-Hans/translation.json b/localization/locales/zh-Hans/translation.json index 420074ce..34de9f90 100644 --- a/localization/locales/zh-Hans/translation.json +++ b/localization/locales/zh-Hans/translation.json @@ -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": "加载中...", diff --git a/localization/locales/zh-Hant/translation.json b/localization/locales/zh-Hant/translation.json index 0da3cadf..20ed36c0 100644 --- a/localization/locales/zh-Hant/translation.json +++ b/localization/locales/zh-Hant/translation.json @@ -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": "載入中...", diff --git a/scripts/afterPack.ts b/scripts/afterPack.ts index df0d84f5..0167ef8f 100644 --- a/scripts/afterPack.ts +++ b/scripts/afterPack.ts @@ -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 */ diff --git a/src/services/git/gitOperations.ts b/src/services/git/gitOperations.ts index cd9a033b..dbe0afeb 100644 --- a/src/services/git/gitOperations.ts +++ b/src/services/git/gitOperations.ts @@ -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 { +export async function getCommitFiles(repoPath: string, commitHash: string): Promise> { // 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 { 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 { - 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, ); diff --git a/src/services/git/index.ts b/src/services/git/index.ts index ff215ebe..f6cdbe2d 100644 --- a/src/services/git/index.ts +++ b/src/services/git/index.ts @@ -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 { + public async getCommitFiles(wikiFolderPath: string, commitHash: string): Promise { return await gitOperations.getCommitFiles(wikiFolderPath, commitHash); } - public async getFileDiff(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise { + public async getFileDiff(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise { return await gitOperations.getFileDiff(wikiFolderPath, commitHash, filePath, maxLines, maxChars); } - public async getFileContent(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise { + public async getFileContent(wikiFolderPath: string, commitHash: string, filePath: string, maxLines?: number, maxChars?: number): Promise { 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 { await gitOperations.addToGitignore(wikiFolderPath, pattern); + // Notify git state change to refresh git log + this.notifyGitStateChange(wikiFolderPath, 'file-change'); } public async isAIGenerateBackupTitleEnabled(): Promise { diff --git a/src/services/git/interface.ts b/src/services/git/interface.ts index 6e0a17fb..c91ff6e0 100644 --- a/src/services/git/interface.ts +++ b/src/services/git/interface.ts @@ -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; + getCommitFiles(wikiFolderPath: string, commitHash: string): Promise; /** * Get the diff for a specific file in a commit * @param maxLines - Maximum number of lines to return (default: 500) diff --git a/src/services/workspaces/getWorkspaceMenuTemplate.ts b/src/services/workspaces/getWorkspaceMenuTemplate.ts index d6b2177e..3e68567b 100644 --- a/src/services/workspaces/getWorkspaceMenuTemplate.ts +++ b/src/services/workspaces/getWorkspaceMenuTemplate.ts @@ -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; diff --git a/src/windows/GitLog/CommitDetailsPanel.tsx b/src/windows/GitLog/CommitDetailsPanel.tsx index 5762cdbb..6cca6d70 100644 --- a/src/windows/GitLog/CommitDetailsPanel.tsx +++ b/src/windows/GitLog/CommitDetailsPanel.tsx @@ -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) => ( { - onFileSelect?.(file === selectedFile ? null : file); + onFileSelect?.(file.path === selectedFile ? null : file.path); }} > + {file.status.charAt(0)} + {file.path} + + } slotProps={{ primary: { variant: 'body2', diff --git a/src/windows/GitLog/FileDiffPanel.tsx b/src/windows/GitLog/FileDiffPanel.tsx index 1e076794..701a5ad5 100644 --- a/src/windows/GitLog/FileDiffPanel.tsx +++ b/src/windows/GitLog/FileDiffPanel.tsx @@ -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(''); const [fileContent, setFileContent] = useState(''); @@ -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 () => { diff --git a/src/windows/GitLog/fileStatusStyles.ts b/src/windows/GitLog/fileStatusStyles.ts new file mode 100644 index 00000000..4a370eef --- /dev/null +++ b/src/windows/GitLog/fileStatusStyles.ts @@ -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}; + `; + } +} diff --git a/src/windows/GitLog/index.tsx b/src/windows/GitLog/index.tsx index 911524b0..6da91296 100644 --- a/src/windows/GitLog/index.tsx +++ b/src/windows/GitLog/index.tsx @@ -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 {displayFiles.map((file, index) => { - const fileName = file.split('/').pop() || file; + const fileName = file.path.split('/').pop() || file.path; return ( - - {fileName} + + {fileName} ); })} @@ -195,6 +198,19 @@ export default function GitHistory(): React.JSX.Element { const [selectedFile, setSelectedFile] = useState(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} /> + + + + {snackbar.message} + + ); } diff --git a/src/windows/GitLog/types.ts b/src/windows/GitLog/types.ts index 1b4a007c..7343d2af 100644 --- a/src/windows/GitLog/types.ts +++ b/src/windows/GitLog/types.ts @@ -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[]; } diff --git a/template/wiki b/template/wiki index 046be23e..282e02bb 160000 --- a/template/wiki +++ b/template/wiki @@ -1 +1 @@ -Subproject commit 046be23ef8603996872c0acf2c012362d0cb394b +Subproject commit 282e02bbcd825926db98fab6fcaf9c94cdb33783