diff --git a/features/subWiki.feature b/features/subWiki.feature index 5db55f35..8b61df7e 100644 --- a/features/subWiki.feature +++ b/features/subWiki.feature @@ -56,6 +56,10 @@ Feature: Sub-Wiki Functionality Then I should see "page body and workspaces" elements with selectors: | div[data-testid^='workspace-']:has-text('wiki') | | div[data-testid^='workspace-']:has-text('SubWikiPreload') | + # Enable file system watch for testing (default is false in production) + When I update workspace "wiki" settings: + | property | value | + | enableFileSystemWatch | true | When I click on a "default wiki workspace button" 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 @@ -84,6 +88,10 @@ Feature: Sub-Wiki Functionality Then I should see "page body and workspaces" elements with selectors: | div[data-testid^='workspace-']:has-text('wiki') | | div[data-testid^='workspace-']:has-text('SubWikiTagTree') | + # Enable file system watch for testing (default is false in production) + When I update workspace "wiki" settings: + | property | value | + | enableFileSystemWatch | true | When I click on a "default wiki workspace button" 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 @@ -113,6 +121,10 @@ Feature: Sub-Wiki Functionality Then I should see "page body and workspaces" elements with selectors: | div[data-testid^='workspace-']:has-text('wiki') | | div[data-testid^='workspace-']:has-text('SubWikiFilter') | + # Enable file system watch for testing (default is false in production) + When I update workspace "wiki" settings: + | property | value | + | enableFileSystemWatch | true | When I click on a "default wiki workspace button" 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 @@ -161,6 +173,10 @@ Feature: Sub-Wiki Functionality 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')" + # Enable file system watch for testing (default is false in production) + When I update workspace "wiki" settings: + | property | value | + | enableFileSystemWatch | true | When I click on a "default wiki workspace button" 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 diff --git a/src/services/git/gitOperations.ts b/src/services/git/gitOperations.ts index edf6c1bd..b4c84753 100644 --- a/src/services/git/gitOperations.ts +++ b/src/services/git/gitOperations.ts @@ -603,8 +603,20 @@ export async function revertCommit(repoPath: string, commitHash: string, commitM /** * Undo a commit by resetting to the previous commit and keeping changes as unstaged * This is similar to GitHub Desktop's "Undo" feature + * Only works on the HEAD commit to prevent unexpected behavior */ export async function undoCommit(repoPath: string, commitHash: string): Promise { + // Verify that the provided commitHash is actually the HEAD commit + const headResult = await gitExec(['rev-parse', 'HEAD'], repoPath); + if (headResult.exitCode !== 0) { + throw new Error('Failed to get HEAD commit'); + } + const headCommit = headResult.stdout.trim(); + + if (commitHash !== headCommit) { + throw new Error('Can only undo the most recent commit (HEAD). The provided commit is not HEAD.'); + } + // Get the parent commit of the current commit const parentResult = await gitExec(['rev-parse', `${commitHash}^`], repoPath); diff --git a/src/services/preferences/interface.ts b/src/services/preferences/interface.ts index 4e981897..f5e36b9f 100644 --- a/src/services/preferences/interface.ts +++ b/src/services/preferences/interface.ts @@ -4,6 +4,37 @@ import { PreferenceChannel } from '@/constants/channels'; import type { HunspellLanguages } from '@/constants/hunspellLanguages'; import type { BehaviorSubject } from 'rxjs'; +/** + * Deep equality check for comparing values (handles primitives, arrays, and objects) + * Used to determine if a value differs from its default + */ +function isEqual(a: unknown, b: unknown): boolean { + // Handle primitives and null/undefined + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== 'object' || typeof b !== 'object') return false; + + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, index) => isEqual(item, b[index])); + } + + // One is array, other is not + if (Array.isArray(a) || Array.isArray(b)) return false; + + // Handle objects + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + + return keysA.every(key => { + const valueA = (a as Record)[key]; + const valueB = (b as Record)[key]; + return isEqual(valueA, valueB); + }); +} + export interface IPreferences { allowPrerelease: boolean; alwaysOnTop: boolean; @@ -99,12 +130,8 @@ export function getPreferenceDifferencesFromDefaults(preferences: IPreferences, const defaultValue = defaults[key]; const preferenceValue = preferences[key]; - // For complex types like objects and arrays, do deep comparison - if (typeof defaultValue === 'object' && typeof preferenceValue === 'object') { - if (JSON.stringify(defaultValue) !== JSON.stringify(preferenceValue)) { - (differences as Record)[key] = preferenceValue; - } - } else if (defaultValue !== preferenceValue) { + // Use deep equality check for all types + if (!isEqual(defaultValue, preferenceValue)) { (differences as Record)[key] = preferenceValue; } }); diff --git a/src/services/workspaces/interface.ts b/src/services/workspaces/interface.ts index 0739efd5..7aa1dcbb 100644 --- a/src/services/workspaces/interface.ts +++ b/src/services/workspaces/interface.ts @@ -5,6 +5,37 @@ import { ProxyPropertyType } from 'electron-ipc-cat/common'; import { BehaviorSubject, Observable } from 'rxjs'; import { SetOptional } from 'type-fest'; +/** + * Deep equality check for comparing values (handles primitives, arrays, and objects) + * Used to determine if a value differs from its default + */ +function isEqual(a: unknown, b: unknown): boolean { + // Handle primitives and null/undefined + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== 'object' || typeof b !== 'object') return false; + + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, index) => isEqual(item, b[index])); + } + + // One is array, other is not + if (Array.isArray(a) || Array.isArray(b)) return false; + + // Handle objects + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + + return keysA.every(key => { + const valueA = (a as Record)[key]; + const valueB = (b as Record)[key]; + return isEqual(valueA, valueB); + }); +} + /** * Fields that not part of config that user can edit. Change of these field won't show "save" button on edit page. */ @@ -416,7 +447,7 @@ export function getDifferencesFromDefaults(workspace: IWikiWorkspace): Partial)[typedKey as string] = workspaceValue; } }); diff --git a/src/windows/GitLog/CommitDetailsPanel.tsx b/src/windows/GitLog/CommitDetailsPanel.tsx index dc0dd088..d14ede67 100644 --- a/src/windows/GitLog/CommitDetailsPanel.tsx +++ b/src/windows/GitLog/CommitDetailsPanel.tsx @@ -170,12 +170,17 @@ export function CommitDetailsPanel( const handleConfirmEditMessage = async (): Promise => { if (isAmending || !commit) return; + // Validate that commit message is not empty after trimming + const trimmedMessage = newCommitMessage.trim(); + if (!trimmedMessage) { + return; + } setIsAmending(true); try { const workspace = await window.service.workspace.get(workspaceID); if (!workspace || !('wikiFolderLocation' in workspace)) return; - await window.service.git.amendCommitMessage(workspace.wikiFolderLocation, newCommitMessage.trim()); + await window.service.git.amendCommitMessage(workspace.wikiFolderLocation, trimmedMessage); setIsEditMessageOpen(false); if (onCommitSuccess) { onCommitSuccess(); @@ -509,7 +514,7 @@ export function CommitDetailsPanel( - diff --git a/src/windows/GitLog/useGitHistoryLogic.ts b/src/windows/GitLog/useGitHistoryLogic.ts index e3789c76..3b649eb0 100644 --- a/src/windows/GitLog/useGitHistoryLogic.ts +++ b/src/windows/GitLog/useGitHistoryLogic.ts @@ -141,6 +141,20 @@ export function useCommitSelection({ } }, [lastChangeType, entries, setSelectedCommit]); + // Auto-select uncommitted changes after undo + useEffect(() => { + if (lastChangeType === 'undo' && entries.length > 0) { + // Find uncommitted entry (hash === '') + const uncommittedEntry = entries.find((entry) => entry.hash === ''); + if (uncommittedEntry) { + setSelectedCommit(uncommittedEntry); + } else { + // If no uncommitted changes, deselect + setSelectedCommit(undefined); + } + } + }, [lastChangeType, entries, setSelectedCommit]); + // Maintain selection across refreshes by hash // Skip if we should select first (manual commit) or if a commit just happened (auto-selection in progress) useEffect(() => { @@ -161,23 +175,11 @@ export function useCommitSelection({ }, [setShouldSelectFirst]); const handleUndoSuccess = useCallback(() => { - // After undo, select the uncommitted changes if available - // The entries will be refreshed automatically, so we set a flag to select uncommitted - // We use a callback approach: find uncommitted entry after data refreshes - const selectUncommitted = () => { - const uncommittedEntry = entries.find((entry) => entry.hash === ''); - if (uncommittedEntry) { - setSelectedCommit(uncommittedEntry); - } else { - // If no uncommitted changes, deselect - setSelectedCommit(undefined); - } - }; - - // Schedule the selection for after entries are updated - // Use setTimeout to ensure entries are already loaded - setTimeout(selectUncommitted, 0); - }, [entries, setSelectedCommit]); + // After undo, we want to select uncommitted changes + // Set a special flag that will be handled by the effect above + // Using lastChangeType 'undo' will trigger the selection logic + setLastChangeType('undo'); + }, [setLastChangeType]); const handleSearch = useCallback( (parameters: ISearchParameters) => {