fix: resolve sidebar stale state after worktree, folder, project, and session mutations (#1038)
* fix: update sidebar store after worktree creation * fix: update sidebar store after worktree removal * fix: update sidebar store with correct key in removeProjectWorktree - Fixed key mismatch in sidebar store updates for worktree removal - Removed worktrees now properly disappear from sidebar - Aligned remove path with create path key resolution * fix: resolve sidebar stale state after folder, worktree, project, and session mutations * fix: now builds * Fix worktree sidebar store key --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
b62faadd15
commit
8fdc1e7e55
@@ -407,7 +407,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentDirectory, syncSessionStructureSignature]);
|
}, [currentDirectory, syncSessionStructureSignature, projects]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -922,7 +922,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
normalizedSessionSearchQuery,
|
normalizedSessionSearchQuery,
|
||||||
filterSessionNodesForSearch,
|
filterSessionNodesForSearch,
|
||||||
buildGroupSearchText,
|
buildGroupSearchText,
|
||||||
getFoldersForScope,
|
foldersMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchEmptyState = (
|
const searchEmptyState = (
|
||||||
@@ -1355,7 +1355,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
expandedSessionGroups={expandedSessionGroups}
|
expandedSessionGroups={expandedSessionGroups}
|
||||||
collapsedGroups={collapsedGroups}
|
collapsedGroups={collapsedGroups}
|
||||||
hideDirectoryControls={hideDirectoryControls}
|
hideDirectoryControls={hideDirectoryControls}
|
||||||
getFoldersForScope={getFoldersForScope}
|
|
||||||
collapsedFolderIds={collapsedFolderIds}
|
collapsedFolderIds={collapsedFolderIds}
|
||||||
toggleFolderCollapse={toggleFolderCollapse}
|
toggleFolderCollapse={toggleFolderCollapse}
|
||||||
renameFolder={renameFolder}
|
renameFolder={renameFolder}
|
||||||
@@ -1393,7 +1392,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
expandedSessionGroups,
|
expandedSessionGroups,
|
||||||
collapsedGroups,
|
collapsedGroups,
|
||||||
hideDirectoryControls,
|
hideDirectoryControls,
|
||||||
getFoldersForScope,
|
|
||||||
collapsedFolderIds,
|
collapsedFolderIds,
|
||||||
toggleFolderCollapse,
|
toggleFolderCollapse,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { SortableDragHandleProps } from './sortableItems';
|
|||||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||||
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { openExternalUrl } from '@/lib/url';
|
import { openExternalUrl } from '@/lib/url';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -42,7 +43,6 @@ type Props = {
|
|||||||
expandedSessionGroups: Set<string>;
|
expandedSessionGroups: Set<string>;
|
||||||
collapsedGroups: Set<string>;
|
collapsedGroups: Set<string>;
|
||||||
hideDirectoryControls: boolean;
|
hideDirectoryControls: boolean;
|
||||||
getFoldersForScope: (scopeKey: string) => SessionFolder[];
|
|
||||||
collapsedFolderIds: Set<string>;
|
collapsedFolderIds: Set<string>;
|
||||||
toggleFolderCollapse: (folderId: string) => void;
|
toggleFolderCollapse: (folderId: string) => void;
|
||||||
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
|
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
|
||||||
@@ -109,7 +109,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
expandedSessionGroups,
|
expandedSessionGroups,
|
||||||
collapsedGroups,
|
collapsedGroups,
|
||||||
hideDirectoryControls,
|
hideDirectoryControls,
|
||||||
getFoldersForScope,
|
|
||||||
collapsedFolderIds,
|
collapsedFolderIds,
|
||||||
toggleFolderCollapse,
|
toggleFolderCollapse,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
@@ -153,6 +152,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
|
|
||||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||||
|
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||||
const isMinimalMode = displayMode === 'minimal';
|
const isMinimalMode = displayMode === 'minimal';
|
||||||
const isExpanded = expandedSessionGroups.has(groupKey);
|
const isExpanded = expandedSessionGroups.has(groupKey);
|
||||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||||
@@ -166,8 +166,8 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
);
|
);
|
||||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||||
const scopeFolders = React.useMemo(
|
const scopeFolders = React.useMemo(
|
||||||
() => folderScopeKey ? getFoldersForScope(folderScopeKey) : [],
|
() => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [],
|
||||||
[folderScopeKey, getFoldersForScope]
|
[folderScopeKey, foldersMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeBySessionId = React.useMemo(() => {
|
const nodeBySessionId = React.useMemo(() => {
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ const areEqual = (prev: Props, next: Props): boolean => {
|
|||||||
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
|
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
|
||||||
if (prev.mobileVariant !== next.mobileVariant) return false;
|
if (prev.mobileVariant !== next.mobileVariant) return false;
|
||||||
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
||||||
|
if (prev.renamingFolderId !== next.renamingFolderId) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
|||||||
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||||
import { dedupeSessionsById, normalizePath } from '../utils';
|
import { dedupeSessionsById, normalizePath } from '../utils';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
|
import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore';
|
||||||
|
|
||||||
type ProjectItem = {
|
type ProjectItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,7 +40,7 @@ type Args = {
|
|||||||
normalizedSessionSearchQuery: string;
|
normalizedSessionSearchQuery: string;
|
||||||
filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[];
|
filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[];
|
||||||
buildGroupSearchText: (group: SessionGroup) => string;
|
buildGroupSearchText: (group: SessionGroup) => string;
|
||||||
getFoldersForScope: (scopeKey: string) => Array<{ name: string }>;
|
foldersMap: SessionFoldersMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSessionSidebarSections = (args: Args) => {
|
export const useSessionSidebarSections = (args: Args) => {
|
||||||
@@ -56,7 +57,7 @@ export const useSessionSidebarSections = (args: Args) => {
|
|||||||
normalizedSessionSearchQuery,
|
normalizedSessionSearchQuery,
|
||||||
filterSessionNodesForSearch,
|
filterSessionNodesForSearch,
|
||||||
buildGroupSearchText,
|
buildGroupSearchText,
|
||||||
getFoldersForScope,
|
foldersMap,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||||
@@ -107,9 +108,8 @@ export const useSessionSidebarSections = (args: Args) => {
|
|||||||
const matchedSessionCount = countNodes(filteredNodes);
|
const matchedSessionCount = countNodes(filteredNodes);
|
||||||
const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery);
|
const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery);
|
||||||
const scopeKey = normalizePath(group.directory ?? null);
|
const scopeKey = normalizePath(group.directory ?? null);
|
||||||
const folderNameMatchCount = scopeKey
|
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
|
||||||
? getFoldersForScope(scopeKey).filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length
|
const folderNameMatchCount = scopeFolders.filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length;
|
||||||
: 0;
|
|
||||||
|
|
||||||
result.set(group, {
|
result.set(group, {
|
||||||
filteredNodes,
|
filteredNodes,
|
||||||
@@ -128,7 +128,7 @@ export const useSessionSidebarSections = (args: Args) => {
|
|||||||
filterSessionNodesForSearch,
|
filterSessionNodesForSearch,
|
||||||
normalizedSessionSearchQuery,
|
normalizedSessionSearchQuery,
|
||||||
buildGroupSearchText,
|
buildGroupSearchText,
|
||||||
getFoldersForScope,
|
foldersMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const searchableProjectSections = React.useMemo(() => {
|
const searchableProjectSections = React.useMemo(() => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
CreateGitWorktreePayload,
|
CreateGitWorktreePayload,
|
||||||
GitWorktreeValidationResult,
|
GitWorktreeValidationResult,
|
||||||
} from '@/lib/api/types';
|
} from '@/lib/api/types';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
|
||||||
type WorktreeListEntry = {
|
type WorktreeListEntry = {
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -316,6 +317,17 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
|
|||||||
|
|
||||||
_worktreeListCache.delete(projectDirectory);
|
_worktreeListCache.delete(projectDirectory);
|
||||||
|
|
||||||
|
// Update sidebar store so new worktree appears immediately
|
||||||
|
const sidebarProjectKey = projectDirectory;
|
||||||
|
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||||
|
const updatedByProject = new Map(currentByProject);
|
||||||
|
const existing = updatedByProject.get(sidebarProjectKey) ?? [];
|
||||||
|
updatedByProject.set(sidebarProjectKey, [...existing, metadata]);
|
||||||
|
useSessionUIStore.setState({
|
||||||
|
availableWorktreesByProject: updatedByProject,
|
||||||
|
availableWorktrees: [...useSessionUIStore.getState().availableWorktrees, metadata],
|
||||||
|
});
|
||||||
|
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +342,7 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
|
|||||||
deleteLocalBranch?: boolean;
|
deleteLocalBranch?: boolean;
|
||||||
remoteName?: string;
|
remoteName?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const projectDirectory = project.path;
|
const projectDirectory = normalizePath(project.path);
|
||||||
|
|
||||||
const deleteRemote = Boolean(options?.deleteRemoteBranch);
|
const deleteRemote = Boolean(options?.deleteRemoteBranch);
|
||||||
const deleteLocalBranch = options?.deleteLocalBranch === true;
|
const deleteLocalBranch = options?.deleteLocalBranch === true;
|
||||||
@@ -347,6 +359,34 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
|
|||||||
|
|
||||||
_worktreeListCache.delete(normalizePath(project.path));
|
_worktreeListCache.delete(normalizePath(project.path));
|
||||||
|
|
||||||
|
// Update sidebar store so removed worktree disappears immediately
|
||||||
|
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||||
|
const sidebarProjectKey = projectDirectory;
|
||||||
|
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||||
|
const updatedByProject = new Map(currentByProject);
|
||||||
|
const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? [];
|
||||||
|
updatedByProject.set(
|
||||||
|
sidebarProjectKey,
|
||||||
|
projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up worktreeMetadata for sessions in the removed worktree
|
||||||
|
const currentMetadata = useSessionUIStore.getState().worktreeMetadata;
|
||||||
|
const updatedMetadata = new Map(currentMetadata);
|
||||||
|
for (const [sid, meta] of currentMetadata.entries()) {
|
||||||
|
if (meta && normalizePath(meta.path) === normalizedWorktreePath) {
|
||||||
|
updatedMetadata.delete(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useSessionUIStore.setState({
|
||||||
|
availableWorktreesByProject: updatedByProject,
|
||||||
|
availableWorktrees: useSessionUIStore.getState().availableWorktrees.filter(
|
||||||
|
(w) => normalizePath(w.path) !== normalizedWorktreePath,
|
||||||
|
),
|
||||||
|
worktreeMetadata: updatedMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
|
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
|
||||||
if (deleteRemote && branchName) {
|
if (deleteRemote && branchName) {
|
||||||
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
|
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getSafeStorage } from './utils/safeStorage';
|
|||||||
import { useDirectoryStore } from './useDirectoryStore';
|
import { useDirectoryStore } from './useDirectoryStore';
|
||||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||||
import { PROJECT_COLORS } from '@/lib/projectMeta';
|
import { PROJECT_COLORS } from '@/lib/projectMeta';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
|
||||||
/** Pick a color key that's least used among existing projects */
|
/** Pick a color key that's least used among existing projects */
|
||||||
const pickAutoColor = (projects: ProjectEntry[]): string => {
|
const pickAutoColor = (projects: ProjectEntry[]): string => {
|
||||||
@@ -399,6 +400,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const current = get();
|
const current = get();
|
||||||
|
const project = current.projects.find((p) => p.id === id);
|
||||||
const nextProjects = current.projects.filter((project) => project.id !== id);
|
const nextProjects = current.projects.filter((project) => project.id !== id);
|
||||||
let nextActiveId = current.activeProjectId;
|
let nextActiveId = current.activeProjectId;
|
||||||
|
|
||||||
@@ -409,6 +411,16 @@ export const useProjectsStore = create<ProjectsStore>()(
|
|||||||
set({ projects: nextProjects, activeProjectId: nextActiveId });
|
set({ projects: nextProjects, activeProjectId: nextActiveId });
|
||||||
persistProjects(nextProjects, nextActiveId);
|
persistProjects(nextProjects, nextActiveId);
|
||||||
|
|
||||||
|
// Clean up worktree entries for the removed project
|
||||||
|
if (project) {
|
||||||
|
const normalizedPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||||
|
useSessionUIStore.setState((s) => {
|
||||||
|
const next = new Map(s.availableWorktreesByProject);
|
||||||
|
next.delete(normalizedPath);
|
||||||
|
return { availableWorktreesByProject: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (nextActiveId) {
|
if (nextActiveId) {
|
||||||
const nextActive = nextProjects.find((project) => project.id === nextActiveId);
|
const nextActive = nextProjects.find((project) => project.id === nextActiveId);
|
||||||
if (nextActive) {
|
if (nextActive) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface SessionFolder {
|
|||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionFoldersMap = Record<string, SessionFolder[]>;
|
export type SessionFoldersMap = Record<string, SessionFolder[]>;
|
||||||
|
|
||||||
interface SessionFoldersState {
|
interface SessionFoldersState {
|
||||||
foldersMap: SessionFoldersMap;
|
foldersMap: SessionFoldersMap;
|
||||||
|
|||||||
@@ -213,7 +213,26 @@ function optimisticRemoveSession(sessionId: string, directory?: string): Session
|
|||||||
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
|
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
|
||||||
const sessionDirectory = getSessionDirectory(sessionId)
|
const sessionDirectory = getSessionDirectory(sessionId)
|
||||||
// Remove from UI immediately, rollback on error
|
// Remove from UI immediately, rollback on error
|
||||||
const snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
|
let snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
|
||||||
|
let removedFromDir: string | null = snapshot ? (sessionDirectory ?? null) : null
|
||||||
|
|
||||||
|
// If the session wasn't in the resolved directory (e.g. archived session
|
||||||
|
// whose original child store was disposed), search all child stores.
|
||||||
|
if (!snapshot && _childStores) {
|
||||||
|
for (const [dir, store] of _childStores.children.entries()) {
|
||||||
|
const current = store.getState()
|
||||||
|
const sessions = [...current.session]
|
||||||
|
const result = Binary.search(sessions, sessionId, (s) => s.id)
|
||||||
|
if (result.found) {
|
||||||
|
snapshot = current.session
|
||||||
|
sessions.splice(result.index, 1)
|
||||||
|
store.setState({ session: sessions })
|
||||||
|
removedFromDir = dir
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ui = useSessionUIStore.getState()
|
const ui = useSessionUIStore.getState()
|
||||||
if (ui.currentSessionId === sessionId) {
|
if (ui.currentSessionId === sessionId) {
|
||||||
ui.setCurrentSession(null)
|
ui.setCurrentSession(null)
|
||||||
@@ -224,7 +243,13 @@ export async function deleteSession(sessionId: string, _options?: Record<string,
|
|||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[session-actions] deleteSession failed", error)
|
console.error("[session-actions] deleteSession failed", error)
|
||||||
if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot })
|
if (snapshot && removedFromDir) {
|
||||||
|
try {
|
||||||
|
getDirectoryStore(removedFromDir).setState({ session: snapshot })
|
||||||
|
} catch {
|
||||||
|
// child store may have been disposed since — ignore rollback
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user