Compare commits

...

2 commits

Author SHA1 Message Date
lin onetwo
8963527b41 v0.13.0-prerelease11 2025-11-25 00:10:55 +08:00
lin onetwo
b4ebaa66df
Fix/menu sync (#659)
* fix: sync not config remote

* fix: git worker no log

* Update src/services/git/registerMenu.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update menuItems.ts

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-25 00:10:19 +08:00
9 changed files with 354 additions and 11 deletions

View file

@ -0,0 +1,128 @@
import { Then, When } from '@cucumber/cucumber';
import { exec as gitExec } from 'dugite';
import fs from 'fs-extra';
import path from 'path';
import { wikiTestRootPath } from '../supports/paths';
import type { ApplicationWorld } from './application';
/**
* Create a bare git repository to use as a local remote for testing sync
*/
When('I create a bare git repository at {string}', async function(this: ApplicationWorld, repoPath: string) {
const actualPath = repoPath.replace('{tmpDir}', wikiTestRootPath);
// Remove if exists
if (await fs.pathExists(actualPath)) {
await fs.remove(actualPath);
}
// Create bare repository
await fs.ensureDir(actualPath);
await gitExec(['init', '--bare'], actualPath);
});
/**
* Verify that a commit with specific message exists in remote repository
*/
Then('the remote repository {string} should contain commit with message {string}', async function(this: ApplicationWorld, remotePath: string, commitMessage: string) {
const actualRemotePath = remotePath.replace('{tmpDir}', wikiTestRootPath);
// Clone the remote to a temporary location to inspect it
const temporaryClonePath = path.join(wikiTestRootPath, `temp-clone-${Date.now()}`);
try {
await gitExec(['clone', actualRemotePath, temporaryClonePath], wikiTestRootPath);
// Check all branches for the commit message
const branchResult = await gitExec(['branch', '-a'], temporaryClonePath);
if (branchResult.exitCode !== 0) {
throw new Error(`Failed to list branches: ${branchResult.stderr}`);
}
// Try to find commits in any branch
let foundCommit = false;
const branches = branchResult.stdout.split('\n').filter(b => b.trim());
for (const branch of branches) {
const branchName = branch.trim().replace('* ', '').replace('remotes/origin/', '');
if (!branchName) continue;
try {
// Checkout the branch
await gitExec(['checkout', branchName], temporaryClonePath);
// Get commit log
const result = await gitExec(['log', '--oneline', '-10'], temporaryClonePath);
if (result.exitCode === 0 && result.stdout.includes(commitMessage)) {
foundCommit = true;
break;
}
} catch {
// Branch might not exist or be checkable, continue to next
continue;
}
}
if (!foundCommit) {
// Get all logs from all branches for error message
const allLogsResult = await gitExec(['log', '--all', '--oneline', '-20'], temporaryClonePath);
throw new Error(`Commit with message "${commitMessage}" not found in any branch. Available commits:\n${allLogsResult.stdout}\n\nBranches:\n${branchResult.stdout}`);
}
} finally {
// Clean up temporary clone
if (await fs.pathExists(temporaryClonePath)) {
await fs.remove(temporaryClonePath);
}
}
});
/**
* Verify that a file exists in remote repository
*/
Then('the remote repository {string} should contain file {string}', async function(this: ApplicationWorld, remotePath: string, filePath: string) {
const actualRemotePath = remotePath.replace('{tmpDir}', wikiTestRootPath);
// Clone the remote to a temporary location to inspect it
const temporaryClonePath = path.join(wikiTestRootPath, `temp-clone-${Date.now()}`);
try {
await gitExec(['clone', actualRemotePath, temporaryClonePath], wikiTestRootPath);
// Check all branches for the file
const branchResult = await gitExec(['branch', '-a'], temporaryClonePath);
if (branchResult.exitCode !== 0) {
throw new Error(`Failed to list branches: ${branchResult.stderr}`);
}
let foundFile = false;
const branches = branchResult.stdout.split('\n').filter(b => b.trim());
for (const branch of branches) {
const branchName = branch.trim().replace('* ', '').replace('remotes/origin/', '');
if (!branchName) continue;
try {
// Checkout the branch
await gitExec(['checkout', branchName], temporaryClonePath);
const fileFullPath = path.join(temporaryClonePath, filePath);
if (await fs.pathExists(fileFullPath)) {
foundFile = true;
break;
}
} catch {
// Branch might not exist or be checkable, continue to next
continue;
}
}
if (!foundFile) {
throw new Error(`File "${filePath}" not found in any branch of remote repository`);
}
} finally {
// Clean up temporary clone
if (await fs.pathExists(temporaryClonePath)) {
await fs.remove(temporaryClonePath);
}
}
});

View file

@ -1,4 +1,5 @@
import { DataTable, Then, When } from '@cucumber/cucumber';
import { wikiTestRootPath } from '../supports/paths';
import type { ApplicationWorld } from './application';
When('I wait for {float} seconds', async function(seconds: number) {
@ -243,15 +244,54 @@ When('I type {string} in {string} element with selector {string}', async functio
throw new Error('No current window is available');
}
// Replace {tmpDir} placeholder with actual test root path
const actualText = text.replace('{tmpDir}', wikiTestRootPath);
try {
await currentWindow.waitForSelector(selector, { timeout: 10000 });
const element = currentWindow.locator(selector);
await element.fill(text);
await element.fill(actualText);
} catch (error) {
throw new Error(`Failed to type in ${elementComment} element with selector "${selector}": ${error as Error}`);
}
});
When('I type in {string} elements with selectors:', async function(this: ApplicationWorld, elementDescriptions: string, dataTable: DataTable) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
const descriptions = elementDescriptions.split(' and ').map(d => d.trim());
const rows = dataTable.raw();
const errors: string[] = [];
if (descriptions.length !== rows.length) {
throw new Error(`Mismatch: ${descriptions.length} element descriptions but ${rows.length} text/selector pairs provided`);
}
// Type in elements sequentially to maintain order
for (let index = 0; index < rows.length; index++) {
const [text, selector] = rows[index];
const elementComment = descriptions[index];
// Replace {tmpDir} placeholder with actual test root path
const actualText = text.replace('{tmpDir}', wikiTestRootPath);
try {
await currentWindow.waitForSelector(selector, { timeout: 10000 });
const element = currentWindow.locator(selector);
await element.fill(actualText);
} catch (error) {
errors.push(`Failed to type in "${elementComment}" with selector "${selector}": ${error as Error}`);
}
}
if (errors.length > 0) {
throw new Error(`Failed to type in some elements:\n${errors.join('\n')}`);
}
});
When('I clear text in {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {

55
features/sync.feature Normal file
View file

@ -0,0 +1,55 @@
Feature: Git Sync
As a user
I want to sync my wiki to a remote repository
So that I can backup and share my content
Background:
Given I cleanup test wiki so it could create a new one on start
And I launch the TidGi application
And I wait for the page to load completely
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
Then the browser view should be loaded and visible
And I wait for SSE and watch-fs to be ready
And I wait for "git initialization" log marker "[test-id-git-init-complete]"
@git @sync
Scenario: Sync to local remote repository via application menu (commit and push)
# Setup a bare git repository as local remote
When I create a bare git repository at "{tmpDir}/remote-repo-menu.git"
# Configure sync via edit workspace window
When I open edit workspace window for workspace with name "wiki"
And I switch to "editWorkspace" window
And I wait for the page to load completely
When I click on "saveAndSyncOptions accordion and syncToCloud toggle" elements with selectors:
| [data-testid='preference-section-saveAndSyncOptions'] |
| [data-testid='synced-local-workspace-switch'] |
And I wait for 1 seconds
When I type in "git url input and github username input and github email input and github token input" elements with selectors:
| {tmpDir}/remote-repo-menu.git | label:has-text('Git线') + * input, label:has-text('Git Repo URL') + * input, input[aria-label='Git线'], input[aria-label='Git Repo URL'] |
| test-user | [data-testid='github-userName-input'] input |
| test@tidgi.test | [data-testid='github-email-input'] input |
| test-token | [data-testid='github-token-input'] input |
When I click on a "save workspace button" element with selector "[data-testid='edit-workspace-save-button']"
# Wait for workspace to be saved (workspace.update triggers a restart which takes time)
And I wait for 5 seconds
When I switch to "main" window
# Create a new tiddler to trigger sync
When I create file "{tmpDir}/wiki/tiddlers/SyncMenuTestTiddler.tid" with content:
"""
created: 20250226090000000
modified: 20250226090000000
title: SyncMenuTestTiddler
tags: SyncTest
This is a test tiddler for sync via menu feature.
"""
Then I wait for tiddler "SyncMenuTestTiddler" to be added by watch-fs
# Clear previous test markers to ensure we're testing fresh sync operation
When I clear test-id markers from logs
# Use application menu to sync (commit and push)
When I click menu " > "
# Wait for git sync to complete (not just commit)
Then I wait for "git sync completed" log marker "[test-id-git-sync-complete]"
# Verify the commit was pushed to remote by cloning the remote and checking
Then the remote repository "{tmpDir}/remote-repo-menu.git" should contain commit with message "使"
And the remote repository "{tmpDir}/remote-repo-menu.git" should contain file "tiddlers/SyncMenuTestTiddler.tid"

View file

@ -2,7 +2,7 @@
"name": "tidgi",
"productName": "TidGi",
"description": "Customizable personal knowledge-base with Github as unlimited storage and blogging platform.",
"version": "0.13.0-prerelease10",
"version": "0.13.0-prerelease11",
"license": "MPL 2.0",
"packageManager": "pnpm@10.18.2",
"scripts": {

View file

@ -326,17 +326,35 @@ export class Git implements IGitService {
return;
}
observable.subscribe(this.getWorkerMessageObserver(wikiFolderPath, () => {}, reject, workspaceID));
let hasChanges = false;
observable.subscribe({
next: (messageObject: IGitLogMessage) => {
// Log the message
if (messageObject.level === 'error') {
const errorMessage = (messageObject.error).message;
// if workspace exists, show notification in workspace, else use dialog instead
if (workspaceID === undefined) {
this.createFailedDialog(errorMessage, wikiFolderPath);
} else {
this.createFailedNotification(errorMessage, workspaceID);
}
// Reject the promise on error to prevent service restart
reject(messageObject.error);
return;
}
const { meta } = messageObject;
if (typeof meta === 'object' && meta !== null && 'step' in meta && stepsAboutChange.includes((meta as { step: GitStep }).step)) {
hasChanges = true;
const { message, meta, level } = messageObject;
if (typeof meta === 'object' && meta !== null && 'step' in meta) {
this.popGitErrorNotificationToUser((meta as { step: GitStep }).step, message);
// Check if this step indicates changes
if (stepsAboutChange.includes((meta as { step: GitStep }).step)) {
hasChanges = true;
}
}
logger.log(level, translateMessage(message), meta);
},
error: (error) => {
// this normally won't happen. And will become unhandled error. Because Observable error can't be catch, don't know why.
reject(error as Error);
},
complete: () => {
resolve(hasChanges);

View file

@ -2,7 +2,7 @@
* Utility functions for creating Git-related menu items
* This file is safe to import from both frontend and backend code
*/
import type { IGitService } from '@services/git/interface';
import type { IGitService, IGitUserInfos } from '@services/git/interface';
import { DeferredMenuItemConstructorOptions } from '@services/menu/interface';
import { IWindowService } from '@services/windows/interface';
import { WindowNames } from '@services/windows/WindowProperties';
@ -118,6 +118,7 @@ export function createBackupMenuItems(
* @param gitService Git service instance (or Pick with commitAndSync)
* @param aiEnabled Whether AI-generated commit messages are enabled
* @param isOnline Whether the network is online (optional, defaults to true)
* @param userInfo User authentication info for git operations
* @returns Array of menu items
*/
export function createSyncMenuItems(
@ -125,7 +126,8 @@ export function createSyncMenuItems(
t: TFunction,
gitService: Pick<IGitService, 'commitAndSync'>,
aiEnabled: boolean,
isOnline?: boolean,
isOnline: boolean,
userInfo: IGitUserInfos | undefined,
): DeferredMenuItemConstructorOptions[];
/**
@ -135,6 +137,7 @@ export function createSyncMenuItems(
* @param gitService Git service instance (or Pick with commitAndSync)
* @param aiEnabled Whether AI-generated commit messages are enabled
* @param isOnline Whether the network is online (optional, defaults to true)
* @param userInfo User authentication info for git operations
* @param useDeferred Set to false for context menu
* @returns Array of menu items
*/
@ -144,6 +147,7 @@ export function createSyncMenuItems(
gitService: Pick<IGitService, 'commitAndSync'>,
aiEnabled: boolean,
isOnline: boolean,
userInfo: IGitUserInfos | undefined,
useDeferred: false,
): import('electron').MenuItemConstructorOptions[];
@ -152,14 +156,15 @@ export function createSyncMenuItems(
t: TFunction,
gitService: Pick<IGitService, 'commitAndSync'>,
aiEnabled: boolean,
isOnline: boolean = true,
isOnline: boolean,
userInfo: IGitUserInfos | undefined,
_useDeferred: boolean = true,
): DeferredMenuItemConstructorOptions[] | import('electron').MenuItemConstructorOptions[] {
if (!isWikiWorkspace(workspace) || !workspace.gitUrl) {
return [];
}
const { wikiFolderLocation } = workspace;
const { wikiFolderLocation, gitUrl } = workspace;
const offlineText = isOnline ? '' : ` (${t('ContextMenu.NoNetworkConnection')})`;
if (aiEnabled) {
@ -172,6 +177,8 @@ export function createSyncMenuItems(
dir: wikiFolderLocation,
commitOnly: false,
commitMessage: t('LOG.CommitBackupMessage'),
remoteUrl: gitUrl,
userInfo,
});
},
},
@ -183,6 +190,8 @@ export function createSyncMenuItems(
dir: wikiFolderLocation,
commitOnly: false,
// Don't provide commitMessage to trigger AI generation
remoteUrl: gitUrl,
userInfo,
});
},
},
@ -197,6 +206,8 @@ export function createSyncMenuItems(
await gitService.commitAndSync(workspace, {
dir: wikiFolderLocation,
commitOnly: false,
remoteUrl: gitUrl,
userInfo,
});
},
},

View file

@ -1,9 +1,12 @@
import type { IAuthenticationService } from '@services/auth/interface';
import { container } from '@services/container';
import type { IContextService } from '@services/context/interface';
import type { IGitService } from '@services/git/interface';
import { i18n } from '@services/libs/i18n';
import type { IMenuService } from '@services/menu/interface';
import { DeferredMenuItemConstructorOptions } from '@services/menu/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import { SupportedStorageServices } from '@services/types';
import type { IWindowService } from '@services/windows/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import type { IWorkspaceService } from '@services/workspaces/interface';
@ -14,12 +17,23 @@ export async function registerMenu(): Promise<void> {
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const gitService = container.get<IGitService>(serviceIdentifier.Git);
const authService = container.get<IAuthenticationService>(serviceIdentifier.Authentication);
const contextService = container.get<IContextService>(serviceIdentifier.Context);
const hasActiveWikiWorkspace = async (): Promise<boolean> => {
const activeWorkspace = await workspaceService.getActiveWorkspace();
return activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace);
};
const hasActiveSyncableWorkspace = async (): Promise<boolean> => {
const activeWorkspace = await workspaceService.getActiveWorkspace();
if (!activeWorkspace || !isWikiWorkspace(activeWorkspace)) return false;
if (activeWorkspace.storageService === SupportedStorageServices.local) return false;
if (!activeWorkspace.gitUrl) return false;
const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService);
return userInfo !== undefined;
};
// Build commit and sync menu items with dynamic enabled/click that checks activeWorkspace at runtime
const commitMenuItems: DeferredMenuItemConstructorOptions[] = [
{
@ -67,8 +81,84 @@ export async function registerMenu(): Promise<void> {
});
}
// Build sync menu items
const syncMenuItems: DeferredMenuItemConstructorOptions[] = [];
const isOnline = await contextService.isOnline();
const offlineText = isOnline ? '' : ` (${i18n.t('ContextMenu.NoNetworkConnection')})`;
if (aiGenerateBackupTitleEnabled) {
syncMenuItems.push(
{
label: () => i18n.t('ContextMenu.SyncNow') + offlineText,
id: 'sync-now',
visible: hasActiveSyncableWorkspace,
enabled: async () => {
const online = await contextService.isOnline();
return online && await hasActiveSyncableWorkspace();
},
click: async () => {
const activeWorkspace = await workspaceService.getActiveWorkspace();
if (activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace)) {
const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService);
await gitService.commitAndSync(activeWorkspace, {
dir: activeWorkspace.wikiFolderLocation,
commitOnly: false,
commitMessage: i18n.t('LOG.CommitBackupMessage'),
remoteUrl: activeWorkspace.gitUrl,
userInfo,
});
}
},
},
{
label: () => i18n.t('ContextMenu.SyncNow') + i18n.t('ContextMenu.WithAI') + offlineText,
id: 'sync-now-ai',
visible: hasActiveSyncableWorkspace,
enabled: async () => {
const online = await contextService.isOnline();
return online && await hasActiveSyncableWorkspace();
},
click: async () => {
const activeWorkspace = await workspaceService.getActiveWorkspace();
if (activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace)) {
const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService);
await gitService.commitAndSync(activeWorkspace, {
dir: activeWorkspace.wikiFolderLocation,
commitOnly: false,
// Don't provide commitMessage to trigger AI generation
remoteUrl: activeWorkspace.gitUrl,
userInfo,
});
}
},
},
);
} else {
syncMenuItems.push({
label: () => i18n.t('ContextMenu.SyncNow') + offlineText,
id: 'sync-now',
visible: hasActiveSyncableWorkspace,
enabled: async () => {
const online = await contextService.isOnline();
return online && await hasActiveSyncableWorkspace();
},
click: async () => {
const activeWorkspace = await workspaceService.getActiveWorkspace();
if (activeWorkspace !== undefined && isWikiWorkspace(activeWorkspace)) {
const userInfo = await authService.getStorageServiceUserInfo(activeWorkspace.storageService);
await gitService.commitAndSync(activeWorkspace, {
dir: activeWorkspace.wikiFolderLocation,
commitOnly: false,
commitMessage: i18n.t('LOG.CommitBackupMessage'),
remoteUrl: activeWorkspace.gitUrl,
userInfo,
});
}
},
});
}
// Add to Wiki menu - basic items (each item checks for active wiki workspace)
await menuService.insertMenu(
'Wiki',

View file

@ -204,7 +204,7 @@ export async function getWorkspaceMenuTemplate(
if (userInfo !== undefined) {
const isOnline = await service.context.isOnline();
const syncItems = createSyncMenuItems(workspace, t, service.git, aiGenerateBackupTitleEnabled, isOnline, false);
const syncItems = createSyncMenuItems(workspace, t, service.git, aiGenerateBackupTitleEnabled, isOnline, userInfo, false);
template.push(...syncItems);
}
}

View file

@ -22,6 +22,7 @@ export function useForm(
return;
}
await window.service.workspace.update(workspace.id, workspace);
await window.service.native.log('info', '[test-id-WORKSPACE_SAVED]', { workspaceId: workspace.id, workspaceName: workspace.name });
if (requestRestartAfterSave) {
requestRestartCountDown();
}