Merge pull request #602 from mark-when/contextMenu2
Browse files- app/components/workbench/FileTree.tsx +115 -33
- package.json +1 -0
- pnpm-lock.yaml +30 -0
app/components/workbench/FileTree.tsx
CHANGED
@@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2 |
import type { FileMap } from '~/lib/stores/files';
|
3 |
import { classNames } from '~/utils/classNames';
|
4 |
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
|
|
5 |
|
6 |
const logger = createScopedLogger('FileTree');
|
7 |
|
@@ -110,6 +111,22 @@ export const FileTree = memo(
|
|
110 |
});
|
111 |
};
|
112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
113 |
return (
|
114 |
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
115 |
{filteredFileList.map((fileOrFolder) => {
|
@@ -121,6 +138,12 @@ export const FileTree = memo(
|
|
121 |
selected={selectedFile === fileOrFolder.fullPath}
|
122 |
file={fileOrFolder}
|
123 |
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
onClick={() => {
|
125 |
onFileSelect?.(fileOrFolder.fullPath);
|
126 |
}}
|
@@ -134,6 +157,12 @@ export const FileTree = memo(
|
|
134 |
folder={fileOrFolder}
|
135 |
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
|
136 |
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
|
|
|
|
|
|
|
|
|
|
|
|
137 |
onClick={() => {
|
138 |
toggleCollapseState(fileOrFolder.fullPath);
|
139 |
}}
|
@@ -156,26 +185,67 @@ interface FolderProps {
|
|
156 |
folder: FolderNode;
|
157 |
collapsed: boolean;
|
158 |
selected?: boolean;
|
|
|
|
|
159 |
onClick: () => void;
|
160 |
}
|
161 |
|
162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
163 |
return (
|
164 |
-
<
|
165 |
-
|
166 |
-
|
167 |
-
!selected,
|
168 |
-
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
169 |
-
})}
|
170 |
-
depth={depth}
|
171 |
-
iconClasses={classNames({
|
172 |
-
'i-ph:caret-right scale-98': collapsed,
|
173 |
-
'i-ph:caret-down scale-98': !collapsed,
|
174 |
-
})}
|
175 |
-
onClick={onClick}
|
176 |
>
|
177 |
-
|
178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
179 |
);
|
180 |
}
|
181 |
|
@@ -183,31 +253,43 @@ interface FileProps {
|
|
183 |
file: FileNode;
|
184 |
selected: boolean;
|
185 |
unsavedChanges?: boolean;
|
|
|
|
|
186 |
onClick: () => void;
|
187 |
}
|
188 |
|
189 |
-
function File({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
190 |
return (
|
191 |
-
<
|
192 |
-
|
193 |
-
'
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
onClick={onClick}
|
201 |
-
>
|
202 |
-
<div
|
203 |
-
className={classNames('flex items-center', {
|
204 |
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
205 |
})}
|
|
|
206 |
>
|
207 |
-
<div
|
208 |
-
|
209 |
-
|
210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
211 |
);
|
212 |
}
|
213 |
|
|
|
2 |
import type { FileMap } from '~/lib/stores/files';
|
3 |
import { classNames } from '~/utils/classNames';
|
4 |
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
5 |
+
import * as ContextMenu from '@radix-ui/react-context-menu';
|
6 |
|
7 |
const logger = createScopedLogger('FileTree');
|
8 |
|
|
|
111 |
});
|
112 |
};
|
113 |
|
114 |
+
const onCopyPath = (fileOrFolder: FileNode | FolderNode) => {
|
115 |
+
try {
|
116 |
+
navigator.clipboard.writeText(fileOrFolder.fullPath);
|
117 |
+
} catch (error) {
|
118 |
+
logger.error(error);
|
119 |
+
}
|
120 |
+
};
|
121 |
+
|
122 |
+
const onCopyRelativePath = (fileOrFolder: FileNode | FolderNode) => {
|
123 |
+
try {
|
124 |
+
navigator.clipboard.writeText(fileOrFolder.fullPath.substring((rootFolder || '').length));
|
125 |
+
} catch (error) {
|
126 |
+
logger.error(error);
|
127 |
+
}
|
128 |
+
};
|
129 |
+
|
130 |
return (
|
131 |
<div className={classNames('text-sm', className, 'overflow-y-auto')}>
|
132 |
{filteredFileList.map((fileOrFolder) => {
|
|
|
138 |
selected={selectedFile === fileOrFolder.fullPath}
|
139 |
file={fileOrFolder}
|
140 |
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
141 |
+
onCopyPath={() => {
|
142 |
+
onCopyPath(fileOrFolder);
|
143 |
+
}}
|
144 |
+
onCopyRelativePath={() => {
|
145 |
+
onCopyRelativePath(fileOrFolder);
|
146 |
+
}}
|
147 |
onClick={() => {
|
148 |
onFileSelect?.(fileOrFolder.fullPath);
|
149 |
}}
|
|
|
157 |
folder={fileOrFolder}
|
158 |
selected={allowFolderSelection && selectedFile === fileOrFolder.fullPath}
|
159 |
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
|
160 |
+
onCopyPath={() => {
|
161 |
+
onCopyPath(fileOrFolder);
|
162 |
+
}}
|
163 |
+
onCopyRelativePath={() => {
|
164 |
+
onCopyRelativePath(fileOrFolder);
|
165 |
+
}}
|
166 |
onClick={() => {
|
167 |
toggleCollapseState(fileOrFolder.fullPath);
|
168 |
}}
|
|
|
185 |
folder: FolderNode;
|
186 |
collapsed: boolean;
|
187 |
selected?: boolean;
|
188 |
+
onCopyPath: () => void;
|
189 |
+
onCopyRelativePath: () => void;
|
190 |
onClick: () => void;
|
191 |
}
|
192 |
|
193 |
+
interface FolderContextMenuProps {
|
194 |
+
onCopyPath?: () => void;
|
195 |
+
onCopyRelativePath?: () => void;
|
196 |
+
children: ReactNode;
|
197 |
+
}
|
198 |
+
|
199 |
+
function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) {
|
200 |
return (
|
201 |
+
<ContextMenu.Item
|
202 |
+
onSelect={onSelect}
|
203 |
+
className="flex items-center gap-2 px-2 py-1.5 outline-0 text-sm text-bolt-elements-textPrimary cursor-pointer ws-nowrap text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive rounded-md"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
204 |
>
|
205 |
+
<span className="size-4 shrink-0"></span>
|
206 |
+
<span>{children}</span>
|
207 |
+
</ContextMenu.Item>
|
208 |
+
);
|
209 |
+
}
|
210 |
+
|
211 |
+
function FileContextMenu({ onCopyPath, onCopyRelativePath, children }: FolderContextMenuProps) {
|
212 |
+
return (
|
213 |
+
<ContextMenu.Root>
|
214 |
+
<ContextMenu.Trigger>{children}</ContextMenu.Trigger>
|
215 |
+
<ContextMenu.Portal>
|
216 |
+
<ContextMenu.Content
|
217 |
+
style={{ zIndex: 998 }}
|
218 |
+
className="border border-bolt-elements-borderColor rounded-md z-context-menu bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 w-56"
|
219 |
+
>
|
220 |
+
<ContextMenu.Group className="p-1 border-b-px border-solid border-bolt-elements-borderColor">
|
221 |
+
<ContextMenuItem onSelect={onCopyPath}>Copy path</ContextMenuItem>
|
222 |
+
<ContextMenuItem onSelect={onCopyRelativePath}>Copy relative path</ContextMenuItem>
|
223 |
+
</ContextMenu.Group>
|
224 |
+
</ContextMenu.Content>
|
225 |
+
</ContextMenu.Portal>
|
226 |
+
</ContextMenu.Root>
|
227 |
+
);
|
228 |
+
}
|
229 |
+
|
230 |
+
function Folder({ folder, collapsed, selected = false, onCopyPath, onCopyRelativePath, onClick }: FolderProps) {
|
231 |
+
return (
|
232 |
+
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
233 |
+
<NodeButton
|
234 |
+
className={classNames('group', {
|
235 |
+
'bg-transparent text-bolt-elements-item-contentDefault hover:text-bolt-elements-item-contentActive hover:bg-bolt-elements-item-backgroundActive':
|
236 |
+
!selected,
|
237 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
238 |
+
})}
|
239 |
+
depth={folder.depth}
|
240 |
+
iconClasses={classNames({
|
241 |
+
'i-ph:caret-right scale-98': collapsed,
|
242 |
+
'i-ph:caret-down scale-98': !collapsed,
|
243 |
+
})}
|
244 |
+
onClick={onClick}
|
245 |
+
>
|
246 |
+
{folder.name}
|
247 |
+
</NodeButton>
|
248 |
+
</FileContextMenu>
|
249 |
);
|
250 |
}
|
251 |
|
|
|
253 |
file: FileNode;
|
254 |
selected: boolean;
|
255 |
unsavedChanges?: boolean;
|
256 |
+
onCopyPath: () => void;
|
257 |
+
onCopyRelativePath: () => void;
|
258 |
onClick: () => void;
|
259 |
}
|
260 |
|
261 |
+
function File({
|
262 |
+
file: { depth, name },
|
263 |
+
onClick,
|
264 |
+
onCopyPath,
|
265 |
+
onCopyRelativePath,
|
266 |
+
selected,
|
267 |
+
unsavedChanges = false,
|
268 |
+
}: FileProps) {
|
269 |
return (
|
270 |
+
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
271 |
+
<NodeButton
|
272 |
+
className={classNames('group', {
|
273 |
+
'bg-transparent hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-item-contentDefault':
|
274 |
+
!selected,
|
275 |
+
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': selected,
|
276 |
+
})}
|
277 |
+
depth={depth}
|
278 |
+
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
|
|
|
|
|
|
|
|
279 |
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
280 |
})}
|
281 |
+
onClick={onClick}
|
282 |
>
|
283 |
+
<div
|
284 |
+
className={classNames('flex items-center', {
|
285 |
+
'group-hover:text-bolt-elements-item-contentActive': !selected,
|
286 |
+
})}
|
287 |
+
>
|
288 |
+
<div className="flex-1 truncate pr-2">{name}</div>
|
289 |
+
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
290 |
+
</div>
|
291 |
+
</NodeButton>
|
292 |
+
</FileContextMenu>
|
293 |
);
|
294 |
}
|
295 |
|
package.json
CHANGED
@@ -58,6 +58,7 @@
|
|
58 |
"@octokit/rest": "^21.0.2",
|
59 |
"@octokit/types": "^13.6.2",
|
60 |
"@openrouter/ai-sdk-provider": "^0.0.5",
|
|
|
61 |
"@radix-ui/react-dialog": "^1.1.2",
|
62 |
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
63 |
"@radix-ui/react-separator": "^1.1.0",
|
|
|
58 |
"@octokit/rest": "^21.0.2",
|
59 |
"@octokit/types": "^13.6.2",
|
60 |
"@openrouter/ai-sdk-provider": "^0.0.5",
|
61 |
+
"@radix-ui/react-context-menu": "^2.2.2",
|
62 |
"@radix-ui/react-dialog": "^1.1.2",
|
63 |
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
64 |
"@radix-ui/react-separator": "^1.1.0",
|
pnpm-lock.yaml
CHANGED
@@ -95,6 +95,9 @@ importers:
|
|
95 |
'@openrouter/ai-sdk-provider':
|
96 |
specifier: ^0.0.5
|
97 |
version: 0.0.5([email protected])
|
|
|
|
|
|
|
98 |
'@radix-ui/react-dialog':
|
99 |
specifier: ^1.1.2
|
100 |
version: 1.1.2(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
@@ -1557,6 +1560,19 @@ packages:
|
|
1557 |
'@types/react':
|
1558 |
optional: true
|
1559 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1560 |
'@radix-ui/[email protected]':
|
1561 |
resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==}
|
1562 |
peerDependencies:
|
@@ -7032,6 +7048,20 @@ snapshots:
|
|
7032 |
optionalDependencies:
|
7033 |
'@types/react': 18.3.12
|
7034 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7035 |
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
7036 |
dependencies:
|
7037 |
react: 18.3.1
|
|
|
95 |
'@openrouter/ai-sdk-provider':
|
96 |
specifier: ^0.0.5
|
97 |
version: 0.0.5([email protected])
|
98 |
+
'@radix-ui/react-context-menu':
|
99 |
+
specifier: ^2.2.2
|
100 |
+
version: 2.2.2(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
101 |
'@radix-ui/react-dialog':
|
102 |
specifier: ^1.1.2
|
103 |
version: 1.1.2(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
|
|
1560 |
'@types/react':
|
1561 |
optional: true
|
1562 |
|
1563 |
+
'@radix-ui/[email protected]':
|
1564 |
+
resolution: {integrity: sha512-99EatSTpW+hRYHt7m8wdDlLtkmTovEe8Z/hnxUPV+SKuuNL5HWNhQI4QSdjZqNSgXHay2z4M3Dym73j9p2Gx5Q==}
|
1565 |
+
peerDependencies:
|
1566 |
+
'@types/react': '*'
|
1567 |
+
'@types/react-dom': '*'
|
1568 |
+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
1569 |
+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
1570 |
+
peerDependenciesMeta:
|
1571 |
+
'@types/react':
|
1572 |
+
optional: true
|
1573 |
+
'@types/react-dom':
|
1574 |
+
optional: true
|
1575 |
+
|
1576 |
'@radix-ui/[email protected]':
|
1577 |
resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==}
|
1578 |
peerDependencies:
|
|
|
7048 |
optionalDependencies:
|
7049 |
'@types/react': 18.3.12
|
7050 |
|
7051 |
+
'@radix-ui/[email protected](@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])':
|
7052 |
+
dependencies:
|
7053 |
+
'@radix-ui/primitive': 1.1.0
|
7054 |
+
'@radix-ui/react-context': 1.1.1(@types/[email protected])([email protected])
|
7055 |
+
'@radix-ui/react-menu': 2.1.2(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
7056 |
+
'@radix-ui/react-primitive': 2.0.0(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
7057 |
+
'@radix-ui/react-use-callback-ref': 1.1.0(@types/[email protected])([email protected])
|
7058 |
+
'@radix-ui/react-use-controllable-state': 1.1.0(@types/[email protected])([email protected])
|
7059 |
+
react: 18.3.1
|
7060 |
+
react-dom: 18.3.1([email protected])
|
7061 |
+
optionalDependencies:
|
7062 |
+
'@types/react': 18.3.12
|
7063 |
+
'@types/react-dom': 18.3.1
|
7064 |
+
|
7065 |
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
7066 |
dependencies:
|
7067 |
react: 18.3.1
|