Improve Git log selection and test stability

Refines auto-selection logic in the Git log window to better handle uncommitted changes, commits, reverts, and undos. Updates the feature test to explicitly verify selection and UI state after each operation, improving reliability. Removes unnecessary config re-fetch in FileSystemWatcher and enhances logging for more accurate DOM update detection.
This commit is contained in:
lin onetwo 2026-01-07 14:06:23 +08:00
parent 3d672a392b
commit ffaf5ad33f
6 changed files with 128 additions and 82 deletions

View file

@ -65,36 +65,42 @@ Feature: Git Log Window
# Wait for git log data to stabilize - increased from implicit to explicit
And I wait for 2 seconds for "git log data to load"
Then I should see a "uncommitted changes row" element with selector "[data-testid='uncommitted-changes-row']"
# Click on the uncommitted changes row
When I click on a "uncommitted changes row" element with selector "[data-testid='uncommitted-changes-row']"
# Verify we can see the modified Index.tid file
# Verify uncommitted changes is auto-selected by checking if the file list is visible
Then I should see a "Index.tid file in uncommitted list" element with selector "li:has-text('Index.tid')"
# Switch to Actions tab
# Switch to Actions tab to access commit button
When I click on a "actions tab" element with selector "button[role='tab']:has-text('')"
# Verify the commit now button is visible
Then I should see a "commit now button" element with selector "button[data-testid='commit-now-button']"
# Click the commit now button
When I click on a "commit now button" element with selector "button[data-testid='commit-now-button']"
# Wait for git commit to complete first
Then I wait for "git commit completed" log marker "[test-id-git-commit-complete]"
# Wait for git log data to be updated and rendered to DOM
Then I wait for "git log data rendered to DOM" log marker "[test-id-git-log-data-rendered]"
# After commit, verify the new commit with default message in p tag
And I should see a "commit with default message" element with selector "p.MuiTypography-body2:has-text('使')"
# Don't need to Click on the commit row we just created (contains the commit message) Because we should automatically select it
And I wait for 1 seconds for "commit details panel to load and git lock to release"
# Don't need to Switch to Actions tab to test rollback, because we are already on Actions tab
# Wait for revert button to be visible (should auto-select first commit after commit)
Then I should see a "revert button" element with selector "button:has-text('')"
# Clear old git-log-data-rendered markers to wait for the new one after commit
When I clear log lines containing "[test-id-git-log-data-rendered]"
# Then wait for UI to render the updated data (without uncommitted changes)
Then I wait for "git log data rendered after commit" log marker "[test-id-git-log-data-rendered]"
# Verify that uncommitted changes row is gone (commit was successful)
Then I should not see a "uncommitted changes row" element with selector "[data-testid='uncommitted-changes-row']"
# Verify the correct commit is selected and we're on the latest commit (should show amend button)
Then I should see "selected commit row and commit message and amend button and revert button" elements with selectors:
| [data-testid^='commit-row-'][data-selected='true']:has-text('使') |
| p.MuiTypography-body2:has-text('使') |
| button:has-text('') |
| button:has-text('') |
# Click revert button
When I click on a "revert button" element with selector "button:has-text('')"
# Wait for git revert operation to complete - git operations can be slow on CI and may take longer than usual when system is under load
# The git revert process involves file system operations that may be queued by the OS
# Wait for git revert operation to complete and UI to render the new revert commit
Then I wait for "git revert completed" log marker "[test-id-git-revert-complete]"
When I clear log lines containing "[test-id-git-log-data-rendered]"
Then I wait for "git log data rendered after revert" log marker "[test-id-git-log-data-rendered]"
# Verify that the new revert commit is auto-selected (should contain "回退提交" in the message)
Then I should see a "selected revert commit row" element with selector "[data-testid^='commit-row-'][data-selected='true']:has-text('退')"
# Also verify the revert button is visible (confirms we're on the new commit)
Then I should see a "revert button for the new revert commit" element with selector "button:has-text('')"
# Switch back to main window to verify the revert
When I switch to "main" window
# Wait for file system events to stabilize after git revert - the delete-then-recreate events need time to propagate through nsfw watcher
# The watch-fs plugin uses a 100ms delay to handle git operations that delete-then-recreate files
And I wait for 2 seconds for "file system events to stabilize after git revert"
# Wait for tiddler to be updated by watch-fs after git revert
Then I wait for tiddler "Index" to be updated by watch-fs
# The modified content should be reverted, and make sure file won't be deleted
Then I should not see a "missing tiddler indicator" element in browser view with selector "[data-tiddler-title='Index']:has-text('')"
Then I should not see a "modified content in Index tiddler" element in browser view with selector "[data-tiddler-title='Index']:has-text('Modified Index content')"

View file

@ -156,20 +156,6 @@ export class FileSystemWatcher {
return;
}
// Re-fetch workspace config to get the latest enableFileSystemWatch value
// This ensures we pick up config changes that happened after constructor
if (this.workspaceID) {
try {
const latestConfig = await workspace.get(this.workspaceID);
if (latestConfig && typeof latestConfig === 'object') {
this.workspaceConfig = latestConfig as IWikiWorkspace;
this.logger.log(`FileSystemWatcher Re-fetched workspace config, enableFileSystemWatch=${this.workspaceConfig.enableFileSystemWatch}`);
}
} catch (error) {
this.logger.log(`FileSystemWatcher Failed to re-fetch workspace config: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Check if file system watch is enabled for this workspace
if (this.workspaceConfig && !this.workspaceConfig.enableFileSystemWatch) {
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized (disabled by config)');

View file

@ -31,6 +31,7 @@ export function CommitTableRow({ commit, selected, commitDate, onSelect, onSyncC
selected={selected}
onClick={onSelect}
data-testid={commit.hash === '' ? 'uncommitted-changes-row' : `commit-row-${commit.hash}`}
data-selected={selected}
>
<CellBox sx={{ width: '40%', display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography

View file

@ -98,7 +98,7 @@ export default function GitHistory(): React.JSX.Element {
const renderTooltip = (props: Omit<Parameters<typeof CustomGitTooltip>[0], 't'>) => CustomGitTooltip({ ...props, t });
const { handleCommitSuccess, handleUndoSuccess, handleSearch } = useCommitSelection({
const { handleCommitSuccess, handleRevertSuccess, handleUndoSuccess, handleSearch } = useCommitSelection({
shouldSelectFirst,
entries,
setShouldSelectFirst,
@ -208,7 +208,7 @@ export default function GitHistory(): React.JSX.Element {
onFileSelect={setSelectedFile}
selectedFile={selectedFile}
onCommitSuccess={handleCommitSuccess}
onRevertSuccess={handleCommitSuccess}
onRevertSuccess={handleRevertSuccess}
onUndoSuccess={handleUndoSuccess}
isLatestCommit={selectedCommit?.hash === entries.find((entry) => entry.hash !== '')?.hash}
/>

View file

@ -1,4 +1,4 @@
import { type Dispatch, type SetStateAction, useCallback, useEffect, useState } from 'react';
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { GitLogEntry, ISearchParameters } from './types';
@ -91,6 +91,7 @@ export function useSyncHandler({ workspaceInfo, isSyncing, showSnackbar }: IUseS
interface IUseCommitSelectionReturn {
handleCommitSuccess: () => void;
handleRevertSuccess: () => void;
handleUndoSuccess: () => void;
handleSearch: (parameters: ISearchParameters) => void;
}
@ -120,6 +121,30 @@ export function useCommitSelection({
setCurrentSearchParameters,
setSelectedFile,
}: IUseCommitSelectionProps): IUseCommitSelectionReturn {
// Track if we've already processed the current change type
const lastProcessedChangeRef = useRef<string | null>(null);
// Track if we've done initial selection
const hasInitialSelectionRef = useRef<boolean>(false);
// Auto-select on initial load: uncommitted changes if present, otherwise first commit
useEffect(() => {
if (!hasInitialSelectionRef.current && entries.length > 0 && !selectedCommit) {
// First try to find uncommitted changes
const uncommittedEntry = entries.find((entry) => entry.hash === '');
if (uncommittedEntry) {
setSelectedCommit(uncommittedEntry);
hasInitialSelectionRef.current = true;
} else {
// If no uncommitted changes, select the first commit
const firstCommit = entries[0];
if (firstCommit) {
setSelectedCommit(firstCommit);
hasInitialSelectionRef.current = true;
}
}
}
}, [entries, selectedCommit, setSelectedCommit]);
// Auto-select first commit after successful manual commit
useEffect(() => {
if (shouldSelectFirst && entries.length > 0) {
@ -132,49 +157,69 @@ export function useCommitSelection({
}
}, [shouldSelectFirst, entries, setSelectedCommit, setShouldSelectFirst]);
// Auto-select first commit when a new commit is detected
useEffect(() => {
if (lastChangeType === 'commit' && entries.length > 0) {
// Find the first non-uncommitted commit (the newly created commit)
const firstCommit = entries.find((entry) => entry.hash !== '');
if (firstCommit) {
setSelectedCommit(firstCommit);
}
}
}, [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(() => {
if (selectedCommit && entries.length > 0 && !shouldSelectFirst && lastChangeType !== 'commit') {
if (selectedCommit && entries.length > 0 && !shouldSelectFirst) {
// Try to find the same commit in the new entries
const stillExists = entries.find((entry) => entry.hash === selectedCommit.hash);
// Only update if data actually changed (compare by serialization)
if (stillExists && JSON.stringify(stillExists) !== JSON.stringify(selectedCommit)) {
// Update to the new entry object to get fresh data
setSelectedCommit(stillExists);
if (stillExists) {
// Only update if data actually changed (compare by serialization)
if (JSON.stringify(stillExists) !== JSON.stringify(selectedCommit)) {
// Update to the new entry object to get fresh data
void window.service.native.log('debug', '[test-id-selection-maintained]', { hash: stillExists.hash, message: stillExists.message });
setSelectedCommit(stillExists);
}
} else if (selectedCommit.hash === '') {
// If selected uncommitted changes no longer exist (e.g., after commit)
// Select the first non-uncommitted commit
const firstCommit = entries.find((entry) => entry.hash !== '');
if (firstCommit) {
void window.service.native.log('debug', '[test-id-selection-switched-from-uncommitted]', { oldHash: selectedCommit.hash, newHash: firstCommit.hash, newMessage: firstCommit.message });
setSelectedCommit(firstCommit);
}
}
}
}, [entries, selectedCommit, shouldSelectFirst, lastChangeType, setSelectedCommit]);
}, [entries, selectedCommit, shouldSelectFirst, setSelectedCommit]);
// Handle post-operation selection based on lastChangeType
useEffect(() => {
// Skip if we've already processed this change type
if (lastChangeType && lastChangeType !== lastProcessedChangeRef.current) {
if (lastChangeType === 'revert' && entries.length > 0) {
// After revert, wait for the new revert commit to appear in entries
// The new revert commit should be the first one and different from the currently selected one
const firstCommit = entries.find((entry) => entry.hash !== '');
// Only auto-select if the first commit is different from what's currently selected
// This ensures we're selecting the NEW revert commit, not staying on the old one
if (firstCommit && (!selectedCommit || firstCommit.hash !== selectedCommit.hash)) {
void window.service.native.log('debug', '[test-id-revert-auto-select]', { hash: firstCommit.hash, message: firstCommit.message });
setSelectedCommit(firstCommit);
lastProcessedChangeRef.current = lastChangeType;
}
} else if (lastChangeType === 'undo' && entries.length > 0) {
// After undo, select uncommitted changes if they exist
const uncommittedEntry = entries.find((entry) => entry.hash === '');
if (uncommittedEntry) {
void window.service.native.log('debug', '[test-id-undo-auto-select]', { message: 'Selected uncommitted changes' });
setSelectedCommit(uncommittedEntry);
lastProcessedChangeRef.current = lastChangeType;
}
}
}
}, [lastChangeType, entries, setSelectedCommit, selectedCommit]);
const handleCommitSuccess = useCallback(() => {
// Trigger selection of first commit after data refreshes
setShouldSelectFirst(true);
}, [setShouldSelectFirst]);
// Don't set shouldSelectFirst - let the maintain selection logic handle it
// The uncommitted changes will disappear and it will auto-select the new commit
void window.service.native.log('debug', '[test-id-commit-success-handler]', {});
}, []);
const handleRevertSuccess = useCallback(() => {
// After revert, select the new revert commit (first non-uncommitted)
void window.service.native.log('debug', '[test-id-revert-success-handler]', {});
setLastChangeType('revert');
}, [setLastChangeType]);
const handleUndoSuccess = useCallback(() => {
// After undo, we want to select uncommitted changes
@ -194,7 +239,7 @@ export function useCommitSelection({
[setSearchParams, setCurrentSearchParameters, setSelectedCommit, setSelectedFile],
);
return { handleCommitSuccess, handleUndoSuccess, handleSearch };
return { handleCommitSuccess, handleRevertSuccess, handleUndoSuccess, handleSearch };
}
interface IUseInfiniteScrollReturn {

View file

@ -33,7 +33,6 @@ export function useGitLogData(workspaceID: string): IGitLogData {
const [currentPage, setCurrentPage] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [searchParameters, setSearchParameters] = useState<ISearchParameters>({ mode: 'none', query: '', startDate: null, endDate: null });
const lastLoggedEntriesCount = useRef<number>(0);
const lastRefreshTime = useRef<number>(0);
const lastChangeTimestamp = useRef<number>(0);
const loadingMoreReference = useRef(false);
@ -99,19 +98,21 @@ export function useGitLogData(workspaceID: string): IGitLogData {
return;
}
// User-initiated operations should always trigger refresh immediately
const userOperations = ['commit', 'sync', 'revert', 'undo', 'checkout', 'discard'];
const isUserOperation = change?.type && userOperations.includes(change.type);
// For file-change events, use longer debounce (1000ms) to avoid watch-fs storm
// For other git operations (commit, discard, etc), use shorter debounce (300ms)
// User operations bypass debounce entirely
const debounceTime = change?.type === 'file-change' ? 1000 : 300;
// Allow refresh if enough time has passed since last refresh
if (timeSinceLastRefresh >= debounceTime) {
// Allow refresh if enough time has passed since last refresh OR if it's a user operation
if (isUserOperation || timeSinceLastRefresh >= debounceTime) {
lastRefreshTime.current = now;
lastChangeTimestamp.current = change?.timestamp ?? 0;
// Store the type of change so we can auto-select first commit after a manual commit
// Don't let file-change events override commit/undo events to preserve auto-selection behavior
if (change?.type !== 'file-change' || !lastChangeType || lastChangeType === 'file-change') {
setLastChangeType(change?.type ?? null);
}
setLastChangeType(change?.type ?? null);
// Trigger refresh when git state changes
setRefreshTrigger((previous) => previous + 1);
}
@ -220,17 +221,24 @@ export function useGitLogData(workspaceID: string): IGitLogData {
void loadGitLog();
}, [workspaceInfo, refreshTrigger, searchParameters]);
// Log when entries are updated and rendered to DOM
// Track the last logged entries to detect actual changes
const lastLoggedEntriesRef = useRef<string>('');
// Log when entries are actually updated and rendered to DOM
useEffect(() => {
if (entries.length > 0 && workspaceInfo && 'wikiFolderLocation' in workspaceInfo) {
// Only log if the entries count actually changed (to avoid logging on every re-render)
if (lastLoggedEntriesCount.current !== entries.length) {
lastLoggedEntriesCount.current = entries.length;
// Create a fingerprint of current entries to detect real changes
const entriesFingerprint = entries.map(e => e.hash || 'uncommitted').join(',');
// Only log if entries actually changed
if (entriesFingerprint !== lastLoggedEntriesRef.current) {
lastLoggedEntriesRef.current = entriesFingerprint;
// Use setTimeout to ensure DOM has been updated after state changes
setTimeout(() => {
void window.service.native.log('debug', '[test-id-git-log-data-rendered]', {
commitCount: entries.length,
wikiFolderLocation: workspaceInfo.wikiFolderLocation,
entriesFingerprint,
});
}, 100);
}