mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-01-26 14:31:23 -08:00
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:
parent
45e3f76da1
commit
a674cd269f
17 changed files with 386 additions and 79 deletions
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue