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 ( -
-
{icon}
-
- 图片预览区域 -
-
- ); - } - - // 视频/音频类型:显示占位符 - if (['video', 'audio'].includes(file.fileType)) { - const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频'; - return ( -
-
{icon}
-
{text}
-
- ); - } - - // 其他类型 - return ( -
-
{icon}
-
- 预览不可用 -
-
- ); - }; - - return ( -
-
e.stopPropagation()}> -
-
{file.name}
-
- -
-
-
- {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 ( +
+
{icon}
+
+ 图片预览区域 +
+
+ ); + } + + // 视频/音频类型:显示占位符 + if (['video', 'audio'].includes(file.fileType)) { + const text = file.fileType === 'video' ? '点击下载播放视频' : '点击下载播放音频'; + return ( +
+
{icon}
+
{text}
+
+ ); + } + + // 其他类型 + return ( +
+
{icon}
+
+ 预览不可用 +
+
+ ); + }; + + // 下载操作 + 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 && (