diff --git a/openspec/specs/file-preview/spec.md b/openspec/specs/file-preview/spec.md
index be6382c..15dd7d4 100644
--- a/openspec/specs/file-preview/spec.md
+++ b/openspec/specs/file-preview/spec.md
@@ -1,19 +1,19 @@
## ADDED Requirements
### Requirement: 文件预览弹窗显示文件信息
-文件预览弹窗应显示文件的基本信息。
+文件预览面板应显示文件的基本信息,嵌入在侧边栏右侧,不使用模态弹窗。
#### Scenario: 显示文件基本信息
-- **WHEN** 文件预览弹窗打开
-- **THEN** 弹窗显示文件名、文件类型、文件大小、修改时间
+- **WHEN** 文件预览面板打开
+- **THEN** 面板显示文件名、文件类型、文件大小、修改时间
#### Scenario: 弹窗标题显示文件名
-- **WHEN** 文件预览弹窗打开
-- **THEN** 弹窗 header 显示文件名和关闭按钮(×)
+- **WHEN** 文件预览面板打开
+- **THEN** 面板 header 显示文件图标、文件名和关闭按钮(×)
-#### Scenario: 弹窗宽度适中
-- **WHEN** 文件预览弹窗打开
-- **THEN** 弹窗宽度为 600px,最大宽度为 90vw
+#### Scenario: 面板嵌入侧边栏
+- **WHEN** 文件预览面板打开
+- **THEN** 面板嵌入在侧边栏右侧,不覆盖整个页面
### Requirement: 文本文件预览
文本文件预览应显示文件内容。
@@ -75,15 +75,15 @@ Office 文件预览应显示文件信息和提示。
- **THEN** 文件信息显示音频时长(如 "03:45")
### Requirement: 文件预览弹窗操作
-文件预览弹窗应提供下载和关闭操作。
+文件预览面板应提供下载和关闭操作。
#### Scenario: 点击下载按钮
-- **WHEN** 用户点击预览弹窗的"下载"按钮
+- **WHEN** 用户点击预览面板的"下载"按钮
- **THEN** 显示 Toast 提示"文件下载中..."
#### Scenario: 点击关闭按钮
-- **WHEN** 用户点击预览弹窗的"关闭"按钮
-- **THEN** 预览弹窗关闭
+- **WHEN** 用户点击预览面板的"关闭"按钮
+- **THEN** 预览面板关闭,文件树保持显示
### Requirement: 文件右键菜单
文件项应支持右键菜单操作。
@@ -118,3 +118,33 @@ Office 文件预览应显示文件信息和提示。
#### Scenario: 取消删除
- **WHEN** 用户点击确认弹窗的"取消"按钮
- **THEN** 弹窗关闭,不执行删除操作
+
+### Requirement: 文件预览面板始终可见
+文件预览面板应始终可见,不打断用户操作流程。
+
+#### Scenario: 点击文件立即显示预览
+- **WHEN** 用户点击文件树中的文件
+- **THEN** 预览面板立即在右侧显示文件内容,不弹出模态框
+
+#### Scenario: 切换文件预览面板保持打开
+- **WHEN** 用户点击另一个文件
+- **THEN** 预览面板内容更新为新文件内容,面板保持打开状态
+
+#### Scenario: 预览面板独立滚动
+- **WHEN** 预览文件内容过长
+- **THEN** 预览面板内容区域独立滚动,不影响文件树
+
+### Requirement: 预览面板布局
+预览面板应采用固定布局结构。
+
+#### Scenario: 预览面板布局结构
+- **WHEN** 预览面板显示
+- **THEN** 面板从上到下依次为:Header(文件名+关闭按钮)、Info(文件信息)、Content(文件内容)、Actions(操作按钮)
+
+#### Scenario: 预览面板最小宽度
+- **WHEN** 预览面板显示
+- **THEN** 预览面板最小宽度为 200px,保证内容可读
+
+#### Scenario: 预览面板自适应宽度
+- **WHEN** 侧边栏宽度或文件树宽度改变
+- **THEN** 预览面板宽度自动调整(sidebarWidth - fileTreeWidth - dividerWidth)
diff --git a/openspec/specs/file-tree/spec.md b/openspec/specs/file-tree/spec.md
index 0ed6a54..ae3c747 100644
--- a/openspec/specs/file-tree/spec.md
+++ b/openspec/specs/file-tree/spec.md
@@ -158,3 +158,37 @@
#### Scenario: 包含空文件夹示例
- **WHEN** 文件树加载
- **THEN** 显示至少一个空文件夹
+
+### Requirement: 文件树宽度可调整
+文件树宽度应支持拖动调整。
+
+#### Scenario: 文件树默认宽度
+- **WHEN** 侧边栏打开
+- **THEN** 文件树默认宽度为 200px
+
+#### Scenario: 文件树宽度范围
+- **WHEN** 用户拖动分隔线调整文件树宽度
+- **THEN** 文件树宽度在 180px 到 300px 之间变化
+
+#### Scenario: 文件树宽度约束
+- **WHEN** 文件树宽度调整到边界值
+- **THEN** 最小 180px 保证文件名可读,最大 300px 避免压缩预览面板
+
+### Requirement: 文件树作为侧边栏左侧面板
+文件树应作为侧边栏的左侧面板显示。
+
+#### Scenario: 文件树固定在左侧
+- **WHEN** 侧边栏打开
+- **THEN** 文件树固定在侧边栏左侧,flex-shrink: 0,不被压缩
+
+#### Scenario: 文件树独立滚动
+- **WHEN** 文件树内容过多
+- **THEN** 文件树独立滚动(overflow-y: auto),不影响预览面板
+
+#### Scenario: 文件树背景色
+- **WHEN** 文件树显示
+- **THEN** 文件树背景色为 var(--color-bg-2),与侧边栏整体一致
+
+#### Scenario: 文件树右侧边框
+- **WHEN** 文件树显示
+- **THEN** 文件树右侧显示 1px 边框(var(--color-border-2)),与预览面板分隔
diff --git a/openspec/specs/workspace-sidebar/spec.md b/openspec/specs/workspace-sidebar/spec.md
index a903306..ec6f77b 100644
--- a/openspec/specs/workspace-sidebar/spec.md
+++ b/openspec/specs/workspace-sidebar/spec.md
@@ -30,15 +30,15 @@
- **THEN** 工作空间侧边栏关闭,恢复为隐藏状态
### Requirement: 工作空间侧边栏布局
-工作空间侧边栏应包含 header 区域和 content 区域。
+工作空间侧边栏应包含 header 区域和 content 区域,content 区域采用左右分栏布局。
#### Scenario: 侧边栏 header 显示标题和关闭按钮
- **WHEN** 工作空间侧边栏展开
- **THEN** header 区域显示"工作空间"标题和关闭按钮(×)
-#### Scenario: 侧边栏 content 显示文件树
+#### Scenario: 侧边栏 content 左右分栏
- **WHEN** 工作空间侧边栏展开
-- **THEN** content 区域显示文件树组件,可滚动查看文件列表
+- **THEN** content 区域分为左侧文件树和右侧预览面板,使用 Flex 布局
### Requirement: 侧边栏展开按钮位置
展开按钮应固定在对话界面的右上角,位于 header 下方。
@@ -71,11 +71,11 @@
#### Scenario: 默认宽度
- **WHEN** 侧边栏打开
-- **THEN** 侧边栏默认宽度为 280px
+- **THEN** 侧边栏默认宽度为 500px
#### Scenario: 拖动调整宽度
- **WHEN** 用户拖动侧边栏左边缘的手柄
-- **THEN** 侧边栏宽度随鼠标移动而改变,范围限制在 200px 到 500px
+- **THEN** 侧边栏宽度随鼠标移动而改变,范围限制在 400px 到 800px
#### Scenario: 拖动手柄始终显示
- **WHEN** 侧边栏打开
@@ -122,3 +122,48 @@
#### Scenario: 按钮间距
- **WHEN** 标题栏按钮显示
- **THEN** 按钮之间有 4px 间距
+
+### Requirement: 侧边栏内部左右分栏布局
+侧边栏 content 区域应采用左右分栏布局。
+
+#### Scenario: 文件树在左侧
+- **WHEN** 侧边栏打开
+- **THEN** 文件树显示在左侧,默认宽度 200px,范围 180-300px
+
+#### Scenario: 预览面板在右侧
+- **WHEN** 用户点击文件树中的文件
+- **THEN** 预览面板显示在右侧,宽度自适应(flex: 1)
+
+#### Scenario: 分隔线显示
+- **WHEN** 侧边栏打开
+- **THEN** 文件树和预览面板之间显示 1px 分隔线
+
+### Requirement: 双拖动调整机制
+侧边栏应支持两层拖动调整。
+
+#### Scenario: 外部拖动调整整体宽度
+- **WHEN** 用户拖动侧边栏左边缘手柄
+- **THEN** 侧边栏整体宽度改变(400-800px),文件树宽度不变,预览面板宽度自适应
+
+#### Scenario: 内部拖动调整文件树宽度
+- **WHEN** 用户拖动文件树和预览面板之间的分隔线
+- **THEN** 文件树宽度改变(180-300px),侧边栏整体宽度不变,预览面板宽度自适应
+
+#### Scenario: 分隔线可拖动
+- **WHEN** 用户 hover 分隔线
+- **THEN** 分隔线背景色变为 var(--color-primary),鼠标变为 ew-resize
+
+#### Scenario: 分隔线拖动区域扩大
+- **WHEN** 用户在分隔线附近 4px 范围内按下鼠标
+- **THEN** 开始拖动调整文件树宽度
+
+### Requirement: 对话区域最小宽度约束
+对话区域应保留最小宽度,保证可用性。
+
+#### Scenario: 对话区域最小宽度
+- **WHEN** 侧边栏宽度调整
+- **THEN** 对话区域宽度不小于 480px,保证输入框和消息区可用
+
+#### Scenario: 侧边栏最大宽度限制
+- **WHEN** 对话区域宽度接近 480px
+- **THEN** 侧边栏宽度不再增加,保证对话区域可用性
diff --git a/src/components/workspace/FilePreviewModal.jsx b/src/components/workspace/FilePreviewModal.jsx
deleted file mode 100644
index b17bf98..0000000
--- a/src/components/workspace/FilePreviewModal.jsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { FiX, FiDownload } from 'react-icons/fi';
-import api from '../../services/api.js';
-
-/**
- * 文件预览弹窗组件
- * 根据文件类型显示不同的预览内容
- */
-function FilePreviewModal({ file, onClose }) {
- if (!file) return null;
-
- const icon = api.workspace.getFileIcon(file.fileType);
-
- // 获取文件类型描述
- const getTypeDesc = (fileType) => {
- const typeMap = {
- text: '文本文件',
- markdown: 'Markdown 文件',
- json: 'JSON 文件',
- javascript: 'JavaScript 文件',
- python: 'Python 文件',
- css: 'CSS 文件',
- html: 'HTML 文件',
- word: 'Word 文档',
- excel: 'Excel 文档',
- powerpoint: 'PowerPoint 文档',
- image: '图片文件',
- video: '视频文件',
- audio: '音频文件',
- pdf: 'PDF 文件',
- };
- return typeMap[fileType] || '文件';
- };
-
- // 渲染文件信息
- const renderFileInfo = () => (
-
-
- 类型:
- {getTypeDesc(file.fileType)}
-
-
- 大小:
- {file.size}
-
- {file.dimensions && (
-
- 尺寸:
- {file.dimensions}
-
- )}
- {file.duration && (
-
- 时长:
- {file.duration}
-
- )}
-
- 修改:
- {file.modifiedAt}
-
-
- );
-
- // 渲染预览内容
- const renderPreview = () => {
- // 文本类型:显示内容
- if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) {
- return (
-
-
{file.content || '(无内容)'}
-
- );
- }
-
- // Office 类型:显示占位符
- if (['word', 'excel', 'powerpoint'].includes(file.fileType)) {
- return (
-
-
{icon}
-
- 点击下载查看完整内容
-
-
- );
- }
-
- // 图片类型:显示占位符
- if (file.fileType === 'image') {
- return (
-
- );
- }
-
- // 视频/音频类型:显示占位符
- if (['video', 'audio'].includes(file.fileType)) {
- const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
- return (
-
- );
- }
-
- // 其他类型
- return (
-
- );
- };
-
- return (
-
-
e.stopPropagation()}>
-
-
- {renderFileInfo()}
- {renderPreview()}
-
-
-
-
-
-
-
- );
-}
-
-export default FilePreviewModal;
diff --git a/src/components/workspace/FilePreviewPanel.jsx b/src/components/workspace/FilePreviewPanel.jsx
new file mode 100644
index 0000000..5486adc
--- /dev/null
+++ b/src/components/workspace/FilePreviewPanel.jsx
@@ -0,0 +1,156 @@
+import { FiX, FiDownload } from 'react-icons/fi';
+import api from '../../services/api.js';
+
+/**
+ * 文件预览面板组件
+ * 显示在侧边栏右侧,不打断用户操作
+ */
+function FilePreviewPanel({ file, onClose }) {
+ if (!file) return null;
+
+ const icon = api.workspace.getFileIcon(file.fileType);
+
+ // 获取文件类型描述
+ const getTypeDesc = (fileType) => {
+ const typeMap = {
+ text: '文本文件',
+ markdown: 'Markdown 文件',
+ json: 'JSON 文件',
+ javascript: 'JavaScript 文件',
+ python: 'Python 文件',
+ css: 'CSS 文件',
+ html: 'HTML 文件',
+ word: 'Word 文档',
+ excel: 'Excel 文档',
+ powerpoint: 'PowerPoint 文档',
+ image: '图片文件',
+ video: '视频文件',
+ audio: '音频文件',
+ pdf: 'PDF 文件',
+ };
+ return typeMap[fileType] || '文件';
+ };
+
+ // 渲染文件信息
+ const renderFileInfo = () => (
+
+
+ 类型:
+ {getTypeDesc(file.fileType)}
+
+
+ 大小:
+ {file.size}
+
+ {file.dimensions && (
+
+ 尺寸:
+ {file.dimensions}
+
+ )}
+ {file.duration && (
+
+ 时长:
+ {file.duration}
+
+ )}
+
+ 修改:
+ {file.modifiedAt}
+
+
+ );
+
+ // 渲染预览内容
+ const renderPreview = () => {
+ // 文本类型:显示内容
+ if (['text', 'markdown', 'json', 'javascript', 'python', 'css', 'html'].includes(file.fileType)) {
+ return (
+
+
{file.content || '(无内容)'}
+
+ );
+ }
+
+ // Office 类型:显示占位符
+ if (['word', 'excel', 'powerpoint'].includes(file.fileType)) {
+ return (
+
+
{icon}
+
+ 点击下载查看完整内容
+
+
+ );
+ }
+
+ // 图片类型:显示占位符
+ if (file.fileType === 'image') {
+ return (
+
+ );
+ }
+
+ // 视频/音频类型:显示占位符
+ if (['video', 'audio'].includes(file.fileType)) {
+ const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频';
+ return (
+
+ );
+ }
+
+ // 其他类型
+ return (
+
+ );
+ };
+
+ // 下载操作
+ const handleDownload = () => {
+ alert('文件下载中...');
+ };
+
+ return (
+
+ {/* Header - 文件名 + 关闭按钮 */}
+
+
{icon}
+
{file.name}
+
+
+
+
+
+ {/* Info - 文件信息 */}
+ {renderFileInfo()}
+
+ {/* Content - 文件内容预览 */}
+
+ {renderPreview()}
+
+
+ {/* Actions - 操作按钮 */}
+
+
+
+
+ );
+}
+
+export default FilePreviewPanel;
diff --git a/src/components/workspace/WorkspaceSidebar.jsx b/src/components/workspace/WorkspaceSidebar.jsx
index 361b797..9b28638 100644
--- a/src/components/workspace/WorkspaceSidebar.jsx
+++ b/src/components/workspace/WorkspaceSidebar.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { FiX, FiFolder, FiRefreshCw, FiPlus, FiFilePlus, FiFolderPlus, FiUpload } from 'react-icons/fi';
import FileTree from './FileTree.jsx';
import FileContextMenu from './FileContextMenu.jsx';
-import FilePreviewModal from './FilePreviewModal.jsx';
+import FilePreviewPanel from './FilePreviewPanel.jsx';
import RenameModal from './RenameModal.jsx';
import CreateModal from './CreateModal.jsx';
import FileDetailModal from './FileDetailModal.jsx';
@@ -12,7 +12,7 @@ import api from '../../services/api.js';
/**
* 工作空间侧边栏组件
- * 管理展开/关闭状态,集成文件树、右键菜单和预览弹窗
+ * 支持双拖动调整:整体宽度和文件树宽度
*/
function WorkspaceSidebar({ isOpen, onClose }) {
const [contextMenu, setContextMenu] = useState(null); // { file, position }
@@ -26,19 +26,24 @@ function WorkspaceSidebar({ isOpen, onClose }) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [toast, setToast] = useState(null); // { type, message }
- const [width, setWidth] = useState(280); // 默认宽度 280px
+
+ // 宽度状态
+ const [sidebarWidth, setSidebarWidth] = useState(500); // 侧边栏整体宽度
+ const [fileTreeWidth, setFileTreeWidth] = useState(200); // 文件树宽度
+
+ // Refs
const sidebarRef = useRef(null);
- const isResizingRef = useRef(false);
+ const isResizingSidebarRef = useRef(false); // 是否正在调整侧边栏宽度
+ const isResizingTreeRef = useRef(false); // 是否正在调整文件树宽度
const lastXRef = useRef(0);
const dropdownRef = useRef(null);
- // 拖动调整宽度 - 使用 requestAnimationFrame 优化
- const handleMouseDown = (e) => {
+ // ========== 拖动调整:侧边栏整体宽度 ==========
+ const handleSidebarMouseDown = (e) => {
e.preventDefault();
- isResizingRef.current = true;
+ isResizingSidebarRef.current = true;
lastXRef.current = e.clientX;
- // 添加拖动时的样式
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
@@ -47,36 +52,56 @@ function WorkspaceSidebar({ isOpen, onClose }) {
}
};
+ // ========== 拖动调整:文件树宽度 ==========
+ const handleDividerMouseDown = (e) => {
+ e.preventDefault();
+ e.stopPropagation(); // 防止触发侧边栏拖动
+ isResizingTreeRef.current = true;
+ lastXRef.current = e.clientX;
+
+ document.body.style.cursor = 'ew-resize';
+ document.body.style.userSelect = 'none';
+ };
+
+ // ========== 统一的鼠标移动和释放处理 ==========
useEffect(() => {
let animationFrameId = null;
const handleMouseMove = (e) => {
- if (!isResizingRef.current) return;
+ if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
- // 取消之前的动画帧
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
- // 使用 requestAnimationFrame 批量更新
animationFrameId = requestAnimationFrame(() => {
- const deltaX = lastXRef.current - e.clientX;
+ const deltaX = e.clientX - lastXRef.current;
lastXRef.current = e.clientX;
- setWidth(prevWidth => {
- const newWidth = prevWidth + deltaX;
- // 限制宽度范围:最小 200px,最大 500px
- return Math.max(200, Math.min(500, newWidth));
- });
+ // 调整侧边栏宽度(向左拖动减小,向右拖动增加)
+ if (isResizingSidebarRef.current) {
+ setSidebarWidth(prev => {
+ const newWidth = prev - deltaX; // 注意:向左拖动是负数
+ return Math.max(400, Math.min(800, newWidth));
+ });
+ }
+
+ // 调整文件树宽度(向右拖动增加,向左拖动减小)
+ if (isResizingTreeRef.current) {
+ setFileTreeWidth(prev => {
+ const newWidth = prev + deltaX;
+ return Math.max(180, Math.min(300, newWidth));
+ });
+ }
});
};
const handleMouseUp = () => {
- if (!isResizingRef.current) return;
+ if (!isResizingSidebarRef.current && !isResizingTreeRef.current) return;
- isResizingRef.current = false;
+ isResizingSidebarRef.current = false;
+ isResizingTreeRef.current = false;
- // 恢复样式
document.body.style.cursor = '';
document.body.style.userSelect = '';
@@ -289,7 +314,7 @@ function WorkspaceSidebar({ isOpen, onClose }) {
工作空间
@@ -337,16 +362,41 @@ function WorkspaceSidebar({ isOpen, onClose }) {
+
+ {/* Content - 左右分栏 */}
-
+
+
+
+ {/* 分隔线(可拖动调整) */}
+
+
+ {/* Preview Panel */}
+ {previewFile && (
+
+ setPreviewFile(null)}
+ />
+
+ )}
- {/* 拖动调整宽度的手柄 */}
+
+ {/* 外部拖动手柄(调整侧边栏整体宽度) */}
@@ -373,14 +423,6 @@ function WorkspaceSidebar({ isOpen, onClose }) {
/>
)}
- {/* 文件预览弹窗 */}
- {previewFile && (
-
setPreviewFile(null)}
- />
- )}
-
{/* 删除确认弹窗 */}
{deleteFile && (