From 9b44f4a517d7d7519b069798d28e8650400d755c Mon Sep 17 00:00:00 2001 From: hhhjin Date: Tue, 7 Apr 2026 19:30:48 +0900 Subject: [PATCH] refactor: extract tab slice helpers --- packages/store/src/tab/tab-persistence.ts | 78 +++++ packages/store/src/tab/tab-session.ts | 105 +++++++ packages/store/src/tab/tab-slice.ts | 363 +++++----------------- packages/store/src/tab/tab-state.ts | 133 ++++++++ packages/store/src/tab/tab-types.ts | 38 +++ 5 files changed, 431 insertions(+), 286 deletions(-) create mode 100644 packages/store/src/tab/tab-persistence.ts create mode 100644 packages/store/src/tab/tab-session.ts create mode 100644 packages/store/src/tab/tab-state.ts create mode 100644 packages/store/src/tab/tab-types.ts diff --git a/packages/store/src/tab/tab-persistence.ts b/packages/store/src/tab/tab-persistence.ts new file mode 100644 index 00000000..efdcc6b1 --- /dev/null +++ b/packages/store/src/tab/tab-persistence.ts @@ -0,0 +1,78 @@ +import { relative } from "pathe" +import type { WorkspaceSettings } from "../workspace/workspace-settings" +import { buildPersistedLastOpenedFilePaths } from "./tab-state" +import type { Tab, TabHistoryEntry } from "./tab-types" + +const MAX_PERSISTED_LAST_OPENED_FILE_PATHS = 5 + +type PersistedLastOpenedFileState = { + workspacePath: string | null + tabs: Tab[] + activeTabId: number | null + history: TabHistoryEntry[] +} + +type SaveSettings = ( + workspacePath: string, + settings: Partial, +) => Promise + +export const createLastOpenedFileHistoryPersistence = ({ + getState, + saveSettings, + onError, +}: { + getState: () => PersistedLastOpenedFileState + saveSettings: SaveSettings + onError: (error: unknown) => void +}) => { + let persistQueue: Promise = Promise.resolve() + + const buildPersistInput = (): { + workspacePath: string + lastOpenedFilePaths: string[] + } | null => { + const state = getState() + if (!state.workspacePath) { + return null + } + const workspacePath = state.workspacePath + + return { + workspacePath, + lastOpenedFilePaths: buildPersistedLastOpenedFilePaths( + { + tabs: state.tabs, + activeTabId: state.activeTabId, + history: state.history, + }, + MAX_PERSISTED_LAST_OPENED_FILE_PATHS, + ).map((path) => relative(workspacePath, path)), + } + } + + const enqueue = (): Promise => { + const persistInput = buildPersistInput() + if (!persistInput) { + return Promise.resolve() + } + + const persistTask = persistQueue + .catch(() => {}) + .then(() => + saveSettings(persistInput.workspacePath, { + lastOpenedFilePaths: persistInput.lastOpenedFilePaths, + }), + ) + + persistQueue = persistTask + return persistTask + } + + return { + enqueue, + enqueueSafely: () => { + void enqueue().catch(onError) + }, + } +} diff --git a/packages/store/src/tab/tab-session.ts b/packages/store/src/tab/tab-session.ts new file mode 100644 index 00000000..adc607aa --- /dev/null +++ b/packages/store/src/tab/tab-session.ts @@ -0,0 +1,105 @@ +import type { + PendingHistorySelectionRestoreResult, + TabHistoryEntry, + TabHistorySelection, +} from "./tab-types" +import { + areHistorySelectionsEqual, + cloneHistorySelection, +} from "./utils/history-selection-utils" + +type TabHistoryState = { + history: TabHistoryEntry[] + historyIndex: number +} + +export const updateHistorySelection = ( + state: TabHistoryState, + selection: TabHistorySelection, +): TabHistoryEntry[] | null => { + if ( + state.historyIndex < 0 || + state.historyIndex >= state.history.length || + areHistorySelectionsEqual( + state.history[state.historyIndex].selection, + selection, + ) + ) { + return null + } + + const nextHistory = [...state.history] + nextHistory[state.historyIndex] = { + ...nextHistory[state.historyIndex], + selection, + } + return nextHistory +} + +export const createTabHistorySession = () => { + let selectionProvider: (() => TabHistorySelection) | null = null + let pendingRestore: { + path: string + selection: TabHistorySelection + } | null = null + + return { + setSelectionProvider: (provider: (() => TabHistorySelection) | null) => { + selectionProvider = provider + }, + readCurrentSelection: (): TabHistorySelection => { + if (!selectionProvider) { + return null + } + + try { + return cloneHistorySelection(selectionProvider()) + } catch { + return null + } + }, + queuePendingRestore: (path: string, selection: TabHistorySelection) => { + pendingRestore = { + path, + selection: cloneHistorySelection(selection), + } + }, + clearPendingRestore: () => { + pendingRestore = null + }, + consumePendingRestore: ( + path: string, + ): PendingHistorySelectionRestoreResult => { + if (!pendingRestore || pendingRestore.path !== path) { + return { found: false } + } + + const selection = cloneHistorySelection(pendingRestore.selection) + pendingRestore = null + return { found: true, selection } + }, + } +} + +export const createExternalReloadSaveSkipTracker = () => { + const pendingTabIds = new Set() + + return { + add: (tabId: number) => { + pendingTabIds.add(tabId) + }, + remove: (tabId: number) => { + pendingTabIds.delete(tabId) + }, + clear: () => { + pendingTabIds.clear() + }, + consume: (tabId: number): boolean => { + const shouldSkip = pendingTabIds.has(tabId) + if (shouldSkip) { + pendingTabIds.delete(tabId) + } + return shouldSkip + }, + } +} diff --git a/packages/store/src/tab/tab-slice.ts b/packages/store/src/tab/tab-slice.ts index 90da5441..d7308c80 100644 --- a/packages/store/src/tab/tab-slice.ts +++ b/packages/store/src/tab/tab-slice.ts @@ -6,21 +6,40 @@ import { relative, resolve } from "pathe" import type { StateCreator } from "zustand" import type { WorkspaceSettings } from "../workspace/workspace-settings" import type { WorkspaceSlice } from "../workspace/workspace-slice" +import { createLastOpenedFileHistoryPersistence } from "./tab-persistence" +import { + createExternalReloadSaveSkipTracker, + createTabHistorySession, + updateHistorySelection, +} from "./tab-session" +import { + buildEmptyTabState, + dedupePathsPreservingLastOccurrence, + findTabIndexByPath, + getActiveTabFromState, + getActiveTabSavedFromState, + getTabSavedFromState, + removeTabSaveState, + selectFallbackActiveTabId, +} from "./tab-state" +import type { + OpenTabSnapshot, + PendingHistorySelectionRestoreResult, + Tab, + TabHistoryEntry, + TabHistorySelection, + TabSaveStateMap, +} from "./tab-types" import { appendHistoryEntry, getHistoryNavigationTarget, replaceHistoryPath, } from "./utils/history-navigation-utils" -import { - areHistorySelectionsEqual, - cloneHistorySelection, -} from "./utils/history-selection-utils" import { removePathsFromHistory as removePathsFromHistoryEntries } from "./utils/history-utils" let tabIdCounter = 0 const MAX_HISTORY_LENGTH = 50 -const MAX_PERSISTED_LAST_OPENED_FILE_PATHS = 5 export type TabSliceDependencies = { readTextFile: (path: string) => Promise @@ -31,37 +50,15 @@ export type TabSliceDependencies = { ) => Promise } -export type Tab = { - id: number - path: string - name: string - content: string - syncedName?: string | null -} - -export type TabHistoryPoint = { - path: number[] - offset: number -} - -export type TabHistorySelection = { - anchor: TabHistoryPoint - focus: TabHistoryPoint -} | null - -export type TabHistoryEntry = { - path: string - selection: TabHistorySelection -} - -export type PendingHistorySelectionRestoreResult = - | { - found: false - } - | { - found: true - selection: TabHistorySelection - } +export type { + OpenTabSnapshot, + PendingHistorySelectionRestoreResult, + Tab, + TabHistoryEntry, + TabHistoryPoint, + TabHistorySelection, + TabSaveStateMap, +} from "./tab-types" type RenameTabOptions = { refreshContent?: boolean @@ -78,13 +75,6 @@ type RefreshTabFromExternalContentOptions = { preserveSelection?: boolean } -export type OpenTabSnapshot = { - path: string - isSaved: boolean -} - -export type TabSaveStateMap = Record - export type TabSlice = { tabs: Tab[] activeTabId: number | null @@ -133,121 +123,6 @@ export type TabSlice = { clearHistory: () => void } -type TabStateWithActive = Pick - -const getTabSavedFromState = ( - state: Pick, - tabId: number, -): boolean => state.tabSaveStates[tabId] ?? true - -const getActiveTabFromState = (state: TabStateWithActive): Tab | null => { - if (state.activeTabId === null) { - return null - } - - return state.tabs.find((tab) => tab.id === state.activeTabId) ?? null -} - -const getActiveTabSavedFromState = ( - state: Pick, -): boolean => { - const activeTab = getActiveTabFromState(state) - if (!activeTab) { - return true - } - - return getTabSavedFromState(state, activeTab.id) -} - -const buildEmptyTabState = (): Pick< - TabSlice, - "tabs" | "activeTabId" | "tabSaveStates" -> => ({ - tabs: [], - activeTabId: null, - tabSaveStates: {}, -}) - -const findTabIndexByPath = ( - state: Pick, - path: string, -): number => state.tabs.findIndex((tab) => tab.path === path) - -const removeTabSaveState = ( - tabSaveStates: TabSaveStateMap, - tabId: number, -): TabSaveStateMap => { - if (!Object.hasOwn(tabSaveStates, tabId)) { - return tabSaveStates - } - - const nextTabSaveStates = { ...tabSaveStates } - delete nextTabSaveStates[tabId] - return nextTabSaveStates -} - -const selectFallbackActiveTabId = ( - tabs: readonly Tab[], - removedIndex: number, -): number | null => { - if (tabs.length === 0) { - return null - } - - const fallbackIndex = Math.min( - removedIndex > 0 ? removedIndex - 1 : 0, - tabs.length - 1, - ) - return tabs[fallbackIndex]?.id ?? null -} - -const dedupePathsPreservingLastOccurrence = ( - paths: readonly string[], -): string[] => { - const seen = new Set() - const uniquePaths: string[] = [] - - for (let index = paths.length - 1; index >= 0; index -= 1) { - const path = paths[index] - if (seen.has(path)) { - continue - } - - seen.add(path) - uniquePaths.unshift(path) - } - - return uniquePaths -} - -const buildPersistedLastOpenedFilePaths = ( - state: Pick, -): string[] => { - if (state.tabs.length === 0) { - return [] - } - - const openTabPaths = state.tabs.map((tab) => tab.path) - const openTabPathSet = new Set(openTabPaths) - const historyPaths = dedupePathsPreservingLastOccurrence( - state.history - .map((entry) => entry.path) - .filter((path) => openTabPathSet.has(path)), - ) - const activeTabPath = getActiveTabFromState(state)?.path ?? null - const fallbackPaths = activeTabPath - ? [...openTabPaths.filter((path) => path !== activeTabPath), activeTabPath] - : openTabPaths - - for (const path of fallbackPaths) { - if (!historyPaths.includes(path)) { - historyPaths.push(path) - } - } - - return historyPaths.slice(-MAX_PERSISTED_LAST_OPENED_FILE_PATHS) -} - export const prepareTabSlice = ({ readTextFile, @@ -260,43 +135,36 @@ export const prepareTabSlice = TabSlice > => (set, get) => { - let historySelectionProvider: (() => TabHistorySelection) | null = null - let pendingHistorySelectionRestore: { - path: string - selection: TabHistorySelection - } | null = null - let lastOpenedFileHistoryPersistQueue: Promise = Promise.resolve() - const pendingExternalReloadSaveSkipTabIds = new Set() - - const readCurrentHistorySelection = (): TabHistorySelection => { - if (!historySelectionProvider) { - return null - } - - try { - return cloneHistorySelection(historySelectionProvider()) - } catch { - return null - } - } + const historySession = createTabHistorySession() + const externalReloadSaveSkipTracker = createExternalReloadSaveSkipTracker() + const lastOpenedFileHistoryPersistence = + createLastOpenedFileHistoryPersistence({ + getState: () => { + const state = get() + return { + workspacePath: state.workspacePath, + tabs: state.tabs, + activeTabId: state.activeTabId, + history: state.history, + } + }, + saveSettings, + onError: (error) => { + console.error("Failed to persist last opened file history:", error) + }, + }) const updateCurrentHistorySelection = (selection: TabHistorySelection) => { set((state) => { - if ( - state.historyIndex < 0 || - state.historyIndex >= state.history.length || - areHistorySelectionsEqual( - state.history[state.historyIndex].selection, - selection, - ) - ) { - return {} - } - - const nextHistory = [...state.history] - nextHistory[state.historyIndex] = { - ...nextHistory[state.historyIndex], + const nextHistory = updateHistorySelection( + { + history: state.history, + historyIndex: state.historyIndex, + }, selection, + ) + if (!nextHistory) { + return {} } return { @@ -306,21 +174,7 @@ export const prepareTabSlice = } const commitCurrentHistorySelection = () => { - updateCurrentHistorySelection(readCurrentHistorySelection()) - } - - const queuePendingHistorySelectionRestore = ( - path: string, - selection: TabHistorySelection, - ) => { - pendingHistorySelectionRestore = { - path, - selection: cloneHistorySelection(selection), - } - } - - const clearPendingHistorySelectionRestore = () => { - pendingHistorySelectionRestore = null + updateCurrentHistorySelection(historySession.readCurrentSelection()) } const updateActiveTab = ( @@ -371,52 +225,6 @@ export const prepareTabSlice = }) } - const buildLastOpenedFileHistoryPersistInput = (): { - workspacePath: string - lastOpenedFilePaths: string[] - } | null => { - const workspacePath = get().workspacePath - if (!workspacePath) { - return null - } - - return { - workspacePath, - lastOpenedFilePaths: buildPersistedLastOpenedFilePaths(get()).map( - (path) => relative(workspacePath, path), - ), - } - } - - const persistLastOpenedFileHistory = async (input: { - workspacePath: string - lastOpenedFilePaths: string[] - }) => { - await saveSettings(input.workspacePath, { - lastOpenedFilePaths: input.lastOpenedFilePaths, - }) - } - - const enqueuePersistLastOpenedFileHistory = (): Promise => { - const persistInput = buildLastOpenedFileHistoryPersistInput() - if (!persistInput) { - return Promise.resolve() - } - - const persistTask = lastOpenedFileHistoryPersistQueue - .catch(() => {}) - .then(() => persistLastOpenedFileHistory(persistInput)) - - lastOpenedFileHistoryPersistQueue = persistTask - return persistTask - } - - const persistLastOpenedFileHistorySafely = () => { - void enqueuePersistLastOpenedFileHistory().catch((error) => { - console.error("Failed to persist last opened file history:", error) - }) - } - const setAndPersistLastOpenedFileHistory = ( updater: (state: TabSlice) => Partial | {}, ): boolean => { @@ -429,7 +237,7 @@ export const prepareTabSlice = }) if (didChange) { - persistLastOpenedFileHistorySafely() + lastOpenedFileHistoryPersistence.enqueueSafely() } return didChange @@ -441,7 +249,7 @@ export const prepareTabSlice = } updateHistoryForOpenedTab(path) - await enqueuePersistLastOpenedFileHistory() + await lastOpenedFileHistoryPersistence.enqueue() } const navigateHistory = async ( @@ -460,7 +268,7 @@ export const prepareTabSlice = } commitCurrentHistorySelection() - queuePendingHistorySelectionRestore( + historySession.queuePendingRestore( navigationTarget.targetEntry.path, navigationTarget.targetEntry.selection, ) @@ -473,7 +281,7 @@ export const prepareTabSlice = }) return true } catch (error) { - clearPendingHistorySelectionRestore() + historySession.clearPendingRestore() console.error(`Failed to go ${direction} in history:`, error) return false } @@ -484,22 +292,10 @@ export const prepareTabSlice = history: [], historyIndex: -1, setHistorySelectionProvider: (provider) => { - historySelectionProvider = provider - }, - consumePendingHistorySelectionRestore: (path) => { - if ( - !pendingHistorySelectionRestore || - pendingHistorySelectionRestore.path !== path - ) { - return { found: false } - } - - const selection = cloneHistorySelection( - pendingHistorySelectionRestore.selection, - ) - clearPendingHistorySelectionRestore() - return { found: true, selection } + historySession.setSelectionProvider(provider) }, + consumePendingHistorySelectionRestore: (path) => + historySession.consumePendingRestore(path), refreshTabFromExternalContent: (path, content, options) => { const state = get() const tabIndex = findTabIndexByPath(state, path) @@ -518,7 +314,7 @@ export const prepareTabSlice = const isActiveTab = state.activeTabId === matchingTab.id const nextSelection = isActiveTab && options?.preserveSelection - ? readCurrentHistorySelection() + ? historySession.readCurrentSelection() : null let didRefresh = false @@ -542,7 +338,7 @@ export const prepareTabSlice = let nextTabSaveStates = currentState.tabSaveStates if (currentState.activeTabId === currentTab.id) { - pendingExternalReloadSaveSkipTabIds.add(currentTab.id) + externalReloadSaveSkipTracker.add(currentTab.id) const nextTab = { ...currentTab, id: ++tabIdCounter, @@ -577,15 +373,10 @@ export const prepareTabSlice = } updateCurrentHistorySelection(nextSelection) - queuePendingHistorySelectionRestore(path, nextSelection) - }, - consumePendingExternalReloadSaveSkip: (tabId) => { - const shouldSkip = pendingExternalReloadSaveSkipTabIds.has(tabId) - if (shouldSkip) { - pendingExternalReloadSaveSkipTabIds.delete(tabId) - } - return shouldSkip + historySession.queuePendingRestore(path, nextSelection) }, + consumePendingExternalReloadSaveSkip: (tabId) => + externalReloadSaveSkipTracker.consume(tabId), hydrateFromOpenedFiles: async (paths: string[]) => { const validPaths = paths .filter((path) => path.endsWith(".md")) @@ -631,7 +422,7 @@ export const prepareTabSlice = return false } - pendingExternalReloadSaveSkipTabIds.clear() + externalReloadSaveSkipTracker.clear() const activeIndex = limitedHistory.length - 1 const tabSaveStates = Object.fromEntries( tabs.map((tab) => [tab.id, true]), @@ -746,7 +537,7 @@ export const prepareTabSlice = } const tab = state.tabs[tabIndex] - pendingExternalReloadSaveSkipTabIds.delete(tab.id) + externalReloadSaveSkipTracker.remove(tab.id) const nextTabs = state.tabs.filter((_, index) => index !== tabIndex) const nextActiveTabId = state.activeTabId === tab.id @@ -765,7 +556,7 @@ export const prepareTabSlice = }) }, closeAllTabs: () => { - pendingExternalReloadSaveSkipTabIds.clear() + externalReloadSaveSkipTracker.clear() set(buildEmptyTabState()) }, renameTab: async (oldPath, newPath, options) => { @@ -958,7 +749,7 @@ export const prepareTabSlice = } for (const removedTab of removedTabs) { - pendingExternalReloadSaveSkipTabIds.delete(removedTab.id) + externalReloadSaveSkipTracker.remove(removedTab.id) } setAndPersistLastOpenedFileHistory((currentState) => { @@ -1009,7 +800,7 @@ export const prepareTabSlice = }) }, clearHistory: () => { - clearPendingHistorySelectionRestore() + historySession.clearPendingRestore() set({ history: [], historyIndex: -1, diff --git a/packages/store/src/tab/tab-state.ts b/packages/store/src/tab/tab-state.ts new file mode 100644 index 00000000..dc50c64d --- /dev/null +++ b/packages/store/src/tab/tab-state.ts @@ -0,0 +1,133 @@ +import type { Tab, TabHistoryEntry, TabSaveStateMap } from "./tab-types" + +type TabStateWithActive = { + tabs: Tab[] + activeTabId: number | null +} + +type TabStateWithSaveMap = { + tabSaveStates: TabSaveStateMap +} + +type TabStateWithHistory = TabStateWithActive & { + history: TabHistoryEntry[] +} + +export type EmptyTabState = { + tabs: Tab[] + activeTabId: number | null + tabSaveStates: TabSaveStateMap +} + +export const getTabSavedFromState = ( + state: TabStateWithSaveMap, + tabId: number, +): boolean => state.tabSaveStates[tabId] ?? true + +export const getActiveTabFromState = ( + state: TabStateWithActive, +): Tab | null => { + if (state.activeTabId === null) { + return null + } + + return state.tabs.find((tab) => tab.id === state.activeTabId) ?? null +} + +export const getActiveTabSavedFromState = ( + state: TabStateWithActive & TabStateWithSaveMap, +): boolean => { + const activeTab = getActiveTabFromState(state) + if (!activeTab) { + return true + } + + return getTabSavedFromState(state, activeTab.id) +} + +export const buildEmptyTabState = (): EmptyTabState => ({ + tabs: [], + activeTabId: null, + tabSaveStates: {}, +}) + +export const findTabIndexByPath = ( + state: Pick, + path: string, +): number => state.tabs.findIndex((tab) => tab.path === path) + +export const removeTabSaveState = ( + tabSaveStates: TabSaveStateMap, + tabId: number, +): TabSaveStateMap => { + if (!Object.hasOwn(tabSaveStates, tabId)) { + return tabSaveStates + } + + const nextTabSaveStates = { ...tabSaveStates } + delete nextTabSaveStates[tabId] + return nextTabSaveStates +} + +export const selectFallbackActiveTabId = ( + tabs: readonly Tab[], + removedIndex: number, +): number | null => { + if (tabs.length === 0) { + return null + } + + const fallbackIndex = Math.min( + removedIndex > 0 ? removedIndex - 1 : 0, + tabs.length - 1, + ) + return tabs[fallbackIndex]?.id ?? null +} + +export const dedupePathsPreservingLastOccurrence = ( + paths: readonly string[], +): string[] => { + const seen = new Set() + const uniquePaths: string[] = [] + + for (let index = paths.length - 1; index >= 0; index -= 1) { + const path = paths[index] + if (seen.has(path)) { + continue + } + + seen.add(path) + uniquePaths.unshift(path) + } + + return uniquePaths +} + +export const buildPersistedLastOpenedFilePaths = ( + state: TabStateWithHistory, + maxPersistedPaths: number, +): string[] => { + if (state.tabs.length === 0) { + return [] + } + + const openTabPaths = state.tabs.map((tab) => tab.path) + const openTabPathSet = new Set(openTabPaths) + const historyPaths = dedupePathsPreservingLastOccurrence( + state.history + .map((entry) => entry.path) + .filter((path) => openTabPathSet.has(path)), + ) + const activeTabPath = getActiveTabFromState(state)?.path ?? null + const fallbackPaths = activeTabPath + ? [...openTabPaths.filter((path) => path !== activeTabPath), activeTabPath] + : openTabPaths + + for (const path of fallbackPaths) { + if (!historyPaths.includes(path)) { + historyPaths.push(path) + } + } + + return historyPaths.slice(-maxPersistedPaths) +} diff --git a/packages/store/src/tab/tab-types.ts b/packages/store/src/tab/tab-types.ts new file mode 100644 index 00000000..78628b91 --- /dev/null +++ b/packages/store/src/tab/tab-types.ts @@ -0,0 +1,38 @@ +export type Tab = { + id: number + path: string + name: string + content: string + syncedName?: string | null +} + +export type TabHistoryPoint = { + path: number[] + offset: number +} + +export type TabHistorySelection = { + anchor: TabHistoryPoint + focus: TabHistoryPoint +} | null + +export type TabHistoryEntry = { + path: string + selection: TabHistorySelection +} + +export type PendingHistorySelectionRestoreResult = + | { + found: false + } + | { + found: true + selection: TabHistorySelection + } + +export type OpenTabSnapshot = { + path: string + isSaved: boolean +} + +export type TabSaveStateMap = Record