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

@ -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;