feat: added terminal tabs and resilient session storage (#265)
- Add per-directory tabbed terminal UI with separate buffers - Persist and restore active tab per directory across sessions - Fallback to in-memory storage when sessionStorage is unavailable
This commit is contained in:
committed by
GitHub
parent
0981194eed
commit
3e145bdbd6
@@ -1,36 +1,77 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TerminalSession } from '@/lib/terminalApi';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
import { closeTerminal } from '@/lib/terminalApi';
|
||||
import { getSafeSessionStorage } from '@/stores/utils/safeStorage';
|
||||
|
||||
export interface TerminalChunk {
|
||||
id: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface TerminalSessionState {
|
||||
directory: string;
|
||||
export type TerminalTab = {
|
||||
id: string;
|
||||
terminalSessionId: string | null;
|
||||
isConnecting: boolean;
|
||||
buffer: string;
|
||||
label: string;
|
||||
bufferChunks: TerminalChunk[];
|
||||
bufferLength: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
isConnecting: boolean;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type DirectoryTerminalState = {
|
||||
tabs: TerminalTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
interface TerminalStore {
|
||||
sessions: Map<string, TerminalSessionState>;
|
||||
sessions: Map<string, DirectoryTerminalState>;
|
||||
nextChunkId: number;
|
||||
nextTabId: number;
|
||||
hasHydrated: boolean;
|
||||
|
||||
getTerminalSession: (directory: string) => TerminalSessionState | undefined;
|
||||
setTerminalSession: (directory: string, terminalSession: TerminalSession) => void;
|
||||
setConnecting: (directory: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, chunk: string) => void;
|
||||
clearTerminalSession: (directory: string) => void;
|
||||
clearBuffer: (directory: string) => void;
|
||||
removeTerminalSession: (directory: string) => void;
|
||||
clearAllTerminalSessions: () => void;
|
||||
ensureDirectory: (directory: string) => void;
|
||||
getDirectoryState: (directory: string) => DirectoryTerminalState | undefined;
|
||||
getActiveTab: (directory: string) => TerminalTab | undefined;
|
||||
|
||||
createTab: (directory: string) => string;
|
||||
setActiveTab: (directory: string, tabId: string) => void;
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
|
||||
clearBuffer: (directory: string, tabId: string) => void;
|
||||
|
||||
removeDirectory: (directory: string) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
let hydrationListenerAttached = false;
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'createdAt'>;
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
type PersistedTerminalStoreState = {
|
||||
sessions: Array<[string, PersistedDirectoryTerminalState]>;
|
||||
nextTabId: number;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const tabIdNumber = (tabId: string): number | null => {
|
||||
const match = /^tab-(\d+)$/.exec(tabId);
|
||||
if (!match) return null;
|
||||
const num = Number(match[1]);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
};
|
||||
|
||||
function normalizeDirectory(dir: string): string {
|
||||
let normalized = dir.trim();
|
||||
@@ -40,148 +81,432 @@ function normalizeDirectory(dir: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const createEmptySessionState = (directory: string): TerminalSessionState => ({
|
||||
directory,
|
||||
const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
id,
|
||||
terminalSessionId: null,
|
||||
isConnecting: false,
|
||||
buffer: '',
|
||||
label,
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
updatedAt: Date.now(),
|
||||
isConnecting: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
export const useTerminalStore = create<TerminalStore>((set, get) => ({
|
||||
sessions: new Map(),
|
||||
nextChunkId: 1,
|
||||
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
|
||||
tabs: [firstTab],
|
||||
activeTabId: firstTab.id,
|
||||
});
|
||||
|
||||
getTerminalSession: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
return get().sessions.get(key);
|
||||
},
|
||||
const findTabIndex = (state: DirectoryTerminalState, tabId: string): number =>
|
||||
state.tabs.findIndex((t) => t.id === tabId);
|
||||
|
||||
setTerminalSession: (directory: string, terminalSession: TerminalSession) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
const shouldResetBuffer =
|
||||
!existing ||
|
||||
existing.terminalSessionId !== terminalSession.sessionId;
|
||||
export const useTerminalStore = create<TerminalStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sessions: new Map(),
|
||||
nextChunkId: 1,
|
||||
nextTabId: 1,
|
||||
hasHydrated: typeof window === 'undefined',
|
||||
|
||||
const baseState = shouldResetBuffer
|
||||
? createEmptySessionState(key)
|
||||
: existing ?? createEmptySessionState(key);
|
||||
ensureDirectory: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
if (!key) return;
|
||||
|
||||
newSessions.set(key, {
|
||||
...baseState,
|
||||
terminalSessionId: terminalSession.sessionId,
|
||||
directory: key,
|
||||
isConnecting: false,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
set((state) => {
|
||||
if (state.sessions.has(key)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
const newSessions = new Map(state.sessions);
|
||||
const tabId = `tab-${state.nextTabId}`;
|
||||
const firstTab = createEmptyTab(tabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(firstTab));
|
||||
|
||||
setConnecting: (directory: string, isConnecting: boolean) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key) ?? createEmptySessionState(key);
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
isConnecting,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
appendToBuffer: (directory: string, chunk: string) => {
|
||||
if (!chunk) {
|
||||
return;
|
||||
getDirectoryState: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
return get().sessions.get(key);
|
||||
},
|
||||
|
||||
getActiveTab: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
if (!entry) return undefined;
|
||||
const activeId = entry.activeTabId;
|
||||
if (!activeId) return entry.tabs[0];
|
||||
return entry.tabs.find((t) => t.id === activeId) ?? entry.tabs[0];
|
||||
},
|
||||
|
||||
createTab: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
if (!key) {
|
||||
return 'tab-invalid';
|
||||
}
|
||||
|
||||
const tabId = `tab-${get().nextTabId}`;
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
|
||||
const nextTabId = state.nextTabId + 1;
|
||||
const labelIndex = (existing?.tabs.length ?? 0) + 1;
|
||||
const label = `Terminal ${labelIndex}`;
|
||||
const tab = createEmptyTab(tabId, label);
|
||||
|
||||
if (!existing) {
|
||||
newSessions.set(key, createEmptyDirectoryState(tab));
|
||||
} else {
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: [...existing.tabs, tab],
|
||||
});
|
||||
}
|
||||
|
||||
return { sessions: newSessions, nextTabId };
|
||||
});
|
||||
|
||||
return tabId;
|
||||
},
|
||||
|
||||
setActiveTab: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
if (existing.activeTabId === tabId) {
|
||||
return state;
|
||||
}
|
||||
if (findTabIndex(existing, tabId) < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
newSessions.set(key, { ...existing, activeTabId: tabId });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: async (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
const tab = entry?.tabs.find((t) => t.id === tabId);
|
||||
const sessionId = tab?.terminalSessionId ?? null;
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await closeTerminal(sessionId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
const newTabId = `tab-${state.nextTabId}`;
|
||||
const newTab = createEmptyTab(newTabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(newTab));
|
||||
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
|
||||
}
|
||||
|
||||
let nextActive = existing.activeTabId;
|
||||
if (existing.activeTabId === tabId) {
|
||||
const fallback = nextTabs[Math.min(idx, nextTabs.length - 1)];
|
||||
nextActive = fallback?.id ?? nextTabs[0]?.id ?? null;
|
||||
}
|
||||
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: nextTabs,
|
||||
activeTabId: nextActive,
|
||||
});
|
||||
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
const shouldResetBuffer = tab.terminalSessionId !== sessionId;
|
||||
|
||||
const nextTab: TerminalTab = {
|
||||
...tab,
|
||||
terminalSessionId: sessionId,
|
||||
isConnecting: false,
|
||||
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
|
||||
};
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = nextTab;
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...nextTabs[idx], isConnecting };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => {
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
const chunkId = state.nextChunkId;
|
||||
const chunkEntry: TerminalChunk = { id: chunkId, data: chunk };
|
||||
|
||||
const bufferChunks = [...tab.bufferChunks, chunkEntry];
|
||||
let bufferLength = tab.bufferLength + chunk.length;
|
||||
|
||||
while (bufferLength > TERMINAL_BUFFER_LIMIT && bufferChunks.length > 1) {
|
||||
const removed = bufferChunks.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
bufferLength -= removed.data.length;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...tab,
|
||||
bufferChunks,
|
||||
bufferLength,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
|
||||
return { sessions: newSessions, nextChunkId: chunkId + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
clearBuffer: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx],
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
removeDirectory: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
newSessions.delete(key);
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ sessions: new Map(), nextChunkId: 1, nextTabId: 1 });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: TERMINAL_STORE_NAME,
|
||||
storage: createJSONStorage(() => getSafeSessionStorage()),
|
||||
partialize: (state): PersistedTerminalStoreState => ({
|
||||
sessions: Array.from(state.sessions.entries()).map(([directory, dirState]) => [
|
||||
directory,
|
||||
{
|
||||
activeTabId: dirState.activeTabId,
|
||||
tabs: dirState.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
terminalSessionId: tab.terminalSessionId,
|
||||
createdAt: tab.createdAt,
|
||||
})),
|
||||
},
|
||||
]),
|
||||
nextTabId: state.nextTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
if (!isRecord(persistedState)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const rawSessions = Array.isArray(persistedState.sessions)
|
||||
? (persistedState.sessions as PersistedTerminalStoreState['sessions'])
|
||||
: [];
|
||||
|
||||
const sessions = new Map<string, DirectoryTerminalState>();
|
||||
let maxTabNum = 0;
|
||||
|
||||
for (const entry of rawSessions) {
|
||||
if (!Array.isArray(entry) || entry.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [directory, rawState] = entry as [unknown, unknown];
|
||||
if (typeof directory !== 'string' || !isRecord(rawState)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawTabs = Array.isArray(rawState.tabs) ? (rawState.tabs as unknown[]) : [];
|
||||
const tabs: TerminalTab[] = [];
|
||||
|
||||
for (const rawTab of rawTabs) {
|
||||
if (!isRecord(rawTab)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof rawTab.id === 'string' ? rawTab.id : null;
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const num = tabIdNumber(id);
|
||||
if (num !== null) {
|
||||
maxTabNum = Math.max(maxTabNum, num);
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
id,
|
||||
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
|
||||
terminalSessionId:
|
||||
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
|
||||
? (rawTab.terminalSessionId as string | null)
|
||||
: null,
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
isConnecting: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeTabId =
|
||||
typeof rawState.activeTabId === 'string' ? (rawState.activeTabId as string) : null;
|
||||
const activeExists = activeTabId ? tabs.some((t) => t.id === activeTabId) : false;
|
||||
|
||||
sessions.set(directory, {
|
||||
tabs,
|
||||
activeTabId: activeExists ? activeTabId : tabs[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
const persistedNextTabId =
|
||||
typeof persistedState.nextTabId === 'number' && Number.isFinite(persistedState.nextTabId)
|
||||
? (persistedState.nextTabId as number)
|
||||
: 1;
|
||||
|
||||
const nextTabId = Math.max(currentState.nextTabId, persistedNextTabId, maxTabNum + 1);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
sessions,
|
||||
nextChunkId: 1,
|
||||
nextTabId,
|
||||
hasHydrated: true,
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Ensure hydration completes even when no persisted state exists.
|
||||
if (typeof window !== 'undefined' && !hydrationListenerAttached) {
|
||||
hydrationListenerAttached = true;
|
||||
const persistApi = (
|
||||
useTerminalStore as unknown as {
|
||||
persist?: {
|
||||
hasHydrated?: () => boolean;
|
||||
onFinishHydration?: (cb: () => void) => (() => void) | void;
|
||||
};
|
||||
}
|
||||
).persist;
|
||||
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key) ?? createEmptySessionState(key);
|
||||
const markHydrated = () => {
|
||||
if (!useTerminalStore.getState().hasHydrated) {
|
||||
useTerminalStore.setState({ hasHydrated: true });
|
||||
}
|
||||
};
|
||||
|
||||
const chunkId = state.nextChunkId;
|
||||
const chunkEntry: TerminalChunk = { id: chunkId, data: chunk };
|
||||
|
||||
const bufferChunks = [...existing.bufferChunks, chunkEntry];
|
||||
let bufferLength = existing.bufferLength + chunk.length;
|
||||
|
||||
while (bufferLength > TERMINAL_BUFFER_LIMIT && bufferChunks.length > 1) {
|
||||
const removed = bufferChunks.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
bufferLength -= removed.data.length;
|
||||
}
|
||||
|
||||
const buffer = bufferChunks.map((entry) => entry.data).join('');
|
||||
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
buffer,
|
||||
bufferChunks,
|
||||
bufferLength,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return { sessions: newSessions, nextChunkId: chunkId + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
clearTerminalSession: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (existing) {
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
terminalSessionId: null,
|
||||
isConnecting: false,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
clearBuffer: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
buffer: '',
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
removeTerminalSession: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
newSessions.delete(key);
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
clearAllTerminalSessions: () => {
|
||||
set({ sessions: new Map(), nextChunkId: 1 });
|
||||
},
|
||||
}));
|
||||
if (persistApi?.hasHydrated?.()) {
|
||||
markHydrated();
|
||||
} else if (persistApi?.onFinishHydration) {
|
||||
persistApi.onFinishHydration(markHydrated);
|
||||
} else {
|
||||
markHydrated();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
let safeStorageInstance: Storage | null = null;
|
||||
let safeSessionStorageInstance: Storage | null = null;
|
||||
|
||||
const createInMemoryStorage = (): Storage => {
|
||||
const store = new Map<string, string>();
|
||||
@@ -119,3 +120,101 @@ export const getSafeStorage = (): Storage => {
|
||||
return safeStorageInstance;
|
||||
};
|
||||
|
||||
const createSafeSessionStorage = (): Storage => {
|
||||
if (typeof window === 'undefined' || !window.sessionStorage) {
|
||||
return createInMemoryStorage();
|
||||
}
|
||||
|
||||
const baseStorage = window.sessionStorage;
|
||||
const fallback = createInMemoryStorage();
|
||||
let storageAvailable = true;
|
||||
|
||||
const disableStorage = () => {
|
||||
storageAvailable = false;
|
||||
};
|
||||
|
||||
const safeGet = (key: string): string | null => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
const value = baseStorage.getItem(key);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
return fallback.getItem(key);
|
||||
};
|
||||
|
||||
const safeSet = (key: string, value: string) => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.setItem(key, value);
|
||||
fallback.removeItem(key);
|
||||
return;
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
fallback.setItem(key, value);
|
||||
};
|
||||
|
||||
const safeRemove = (key: string) => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.removeItem(key);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
fallback.removeItem(key);
|
||||
};
|
||||
|
||||
const safeClear = () => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
baseStorage.clear();
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
fallback.clear();
|
||||
};
|
||||
|
||||
const safeKey = (index: number): string | null => {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
return baseStorage.key(index);
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
return fallback.key(index);
|
||||
};
|
||||
|
||||
return {
|
||||
getItem: safeGet,
|
||||
setItem: safeSet,
|
||||
removeItem: safeRemove,
|
||||
clear: safeClear,
|
||||
key: safeKey,
|
||||
get length() {
|
||||
if (storageAvailable) {
|
||||
try {
|
||||
return baseStorage.length + fallback.length;
|
||||
} catch {
|
||||
disableStorage();
|
||||
}
|
||||
}
|
||||
return fallback.length;
|
||||
},
|
||||
} as Storage;
|
||||
};
|
||||
|
||||
export const getSafeSessionStorage = (): Storage => {
|
||||
if (!safeSessionStorageInstance) {
|
||||
safeSessionStorageInstance = createSafeSessionStorage();
|
||||
}
|
||||
return safeSessionStorageInstance;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user