feat(ui): 添加编辑器和文件浏览器状态持久化
- FileExplorer: 保存展开的目录路径到 localStorage - IDE: 保存打开的标签页和活动标签,刷新后自动恢复 - App: 调整 IDE 面板默认宽度为 70%
This commit is contained in:
@@ -132,11 +132,21 @@ function TreeNode({ node, depth, onFileSelect, expandedPaths, onToggleExpand }:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_EXPANDED = 'ai-assistant-file-explorer-expanded';
|
||||||
|
|
||||||
export function FileExplorer({ onFileSelect, className, workingDirectory }: FileExplorerProps) {
|
export function FileExplorer({ onFileSelect, className, workingDirectory }: FileExplorerProps) {
|
||||||
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
// 从 localStorage 恢复展开状态
|
||||||
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY_EXPANDED);
|
||||||
|
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 加载文件树
|
// 加载文件树
|
||||||
const loadTree = useCallback(async () => {
|
const loadTree = useCallback(async () => {
|
||||||
@@ -146,13 +156,7 @@ export function FileExplorer({ onFileSelect, className, workingDirectory }: File
|
|||||||
const result = await getFileTree('.', 4);
|
const result = await getFileTree('.', 4);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setTree(result.data.tree);
|
setTree(result.data.tree);
|
||||||
// 默认展开第一层
|
// 保持已保存的展开状态(不重置)
|
||||||
const firstLevel = new Set(
|
|
||||||
result.data.tree
|
|
||||||
.filter((n) => n.type === 'directory')
|
|
||||||
.map((n) => n.path)
|
|
||||||
);
|
|
||||||
setExpandedPaths(firstLevel);
|
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to load file tree');
|
setError('Failed to load file tree');
|
||||||
}
|
}
|
||||||
@@ -175,6 +179,8 @@ export function FileExplorer({ onFileSelect, className, workingDirectory }: File
|
|||||||
} else {
|
} else {
|
||||||
next.add(path);
|
next.add(path);
|
||||||
}
|
}
|
||||||
|
// 保存到 localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY_EXPANDED, JSON.stringify([...next]));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -4,13 +4,23 @@
|
|||||||
* 整合文件浏览器和代码编辑器的 IDE 组件
|
* 整合文件浏览器和代码编辑器的 IDE 组件
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '../utils/cn.js';
|
import { cn } from '../utils/cn.js';
|
||||||
import { readFile, getWorkingDirectory } from '../api/client.js';
|
import { readFile, getWorkingDirectory } from '../api/client.js';
|
||||||
import { FileExplorer } from './FileExplorer.js';
|
import { FileExplorer } from './FileExplorer.js';
|
||||||
import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js';
|
import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js';
|
||||||
|
|
||||||
|
// localStorage 键名
|
||||||
|
const STORAGE_KEY_TABS = 'ai-assistant-editor-tabs';
|
||||||
|
const STORAGE_KEY_ACTIVE_TAB = 'ai-assistant-editor-active-tab';
|
||||||
|
|
||||||
|
// 保存的标签页格式(只保存路径,不保存内容)
|
||||||
|
interface SavedTab {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface IDEProps {
|
interface IDEProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 文件浏览器宽度 */
|
/** 文件浏览器宽度 */
|
||||||
@@ -21,6 +31,8 @@ export function IDE({ className, sidebarWidth = 256 }: IDEProps) {
|
|||||||
const [tabs, setTabs] = useState<EditorTab[]>([]);
|
const [tabs, setTabs] = useState<EditorTab[]>([]);
|
||||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||||
const [workingDirectory, setWorkingDirectory] = useState<string>('');
|
const [workingDirectory, setWorkingDirectory] = useState<string>('');
|
||||||
|
const isRestoringTabs = useRef(false);
|
||||||
|
const hasRestoredTabs = useRef(false);
|
||||||
|
|
||||||
// 获取工作目录
|
// 获取工作目录
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,6 +47,93 @@ export function IDE({ className, sidebarWidth = 256 }: IDEProps) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 从 localStorage 恢复标签页
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasRestoredTabs.current) return;
|
||||||
|
hasRestoredTabs.current = true;
|
||||||
|
|
||||||
|
const restoreTabs = async () => {
|
||||||
|
try {
|
||||||
|
const savedTabsJson = localStorage.getItem(STORAGE_KEY_TABS);
|
||||||
|
const savedActivePath = localStorage.getItem(STORAGE_KEY_ACTIVE_TAB);
|
||||||
|
|
||||||
|
if (!savedTabsJson) return;
|
||||||
|
|
||||||
|
const savedTabs: SavedTab[] = JSON.parse(savedTabsJson);
|
||||||
|
if (savedTabs.length === 0) return;
|
||||||
|
|
||||||
|
isRestoringTabs.current = true;
|
||||||
|
|
||||||
|
// 逐个恢复标签页
|
||||||
|
const restoredTabs: EditorTab[] = [];
|
||||||
|
for (const savedTab of savedTabs) {
|
||||||
|
try {
|
||||||
|
const result = await readFile(savedTab.path);
|
||||||
|
if (result.success && result.data.encoding === 'utf-8') {
|
||||||
|
restoredTabs.push({
|
||||||
|
id: `tab-${Date.now()}-${restoredTabs.length}`,
|
||||||
|
path: savedTab.path,
|
||||||
|
name: savedTab.name,
|
||||||
|
content: result.data.content,
|
||||||
|
originalContent: result.data.content,
|
||||||
|
language: getLanguageFromFilename(savedTab.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 文件可能已删除,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restoredTabs.length > 0) {
|
||||||
|
setTabs(restoredTabs);
|
||||||
|
|
||||||
|
// 恢复活动标签
|
||||||
|
if (savedActivePath) {
|
||||||
|
const activeTab = restoredTabs.find((tab) => tab.path === savedActivePath);
|
||||||
|
if (activeTab) {
|
||||||
|
setActiveTabId(activeTab.id);
|
||||||
|
} else {
|
||||||
|
setActiveTabId(restoredTabs[0].id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setActiveTabId(restoredTabs[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRestoringTabs.current = false;
|
||||||
|
} catch {
|
||||||
|
isRestoringTabs.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
restoreTabs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 保存标签页到 localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
// 恢复过程中不保存
|
||||||
|
if (isRestoringTabs.current) return;
|
||||||
|
|
||||||
|
const tabsToSave: SavedTab[] = tabs.map((tab) => ({
|
||||||
|
path: tab.path,
|
||||||
|
name: tab.name,
|
||||||
|
}));
|
||||||
|
localStorage.setItem(STORAGE_KEY_TABS, JSON.stringify(tabsToSave));
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
|
// 保存活动标签到 localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
// 恢复过程中不保存
|
||||||
|
if (isRestoringTabs.current) return;
|
||||||
|
|
||||||
|
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||||
|
if (activeTab) {
|
||||||
|
localStorage.setItem(STORAGE_KEY_ACTIVE_TAB, activeTab.path);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_ACTIVE_TAB);
|
||||||
|
}
|
||||||
|
}, [activeTabId, tabs]);
|
||||||
|
|
||||||
// 打开文件
|
// 打开文件
|
||||||
const handleFileSelect = useCallback(async (path: string, name: string) => {
|
const handleFileSelect = useCallback(async (path: string, name: string) => {
|
||||||
// 检查是否已打开
|
// 检查是否已打开
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function App() {
|
|||||||
// IDE 面板宽度(百分比)
|
// IDE 面板宽度(百分比)
|
||||||
const [idePanelWidth, setIdePanelWidth] = useState(() => {
|
const [idePanelWidth, setIdePanelWidth] = useState(() => {
|
||||||
const saved = localStorage.getItem('ai-assistant-ide-width');
|
const saved = localStorage.getItem('ai-assistant-ide-width');
|
||||||
return saved ? parseFloat(saved) : 60;
|
return saved ? parseFloat(saved) : 70;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化:加载会话
|
// 初始化:加载会话
|
||||||
|
|||||||
Reference in New Issue
Block a user