@nextclaw/ui 0.15.24 → 0.15.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/assets/{api-BxPNDp30.js → api-FK7tikI1.js} +3 -3
  3. package/dist/assets/appearance-settings-page-BYbvl2wj.js +1 -0
  4. package/dist/assets/{channels-list-page-DRaNWAxC.js → channels-list-page-CocdcIko.js} +2 -2
  5. package/dist/assets/chat-page-C5E-LLVd.js +1 -0
  6. package/dist/assets/{config-split-page-BVWVDbvG.js → config-split-page-CA_a0AJ5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-mA2iVzxh.js → confirm-dialog-CxZfenjK.js} +1 -1
  8. package/dist/assets/{desktop-update-config-DyQb1LXr.js → desktop-update-config-BEKC8Ly9.js} +1 -1
  9. package/dist/assets/{dist-B4Pf5X3Q.js → dist-BH7W5yCi.js} +1 -1
  10. package/dist/assets/{dist-DHEWkGWX.js → dist-CaYdVVeO.js} +1 -1
  11. package/dist/assets/doc-browser-Cl2ldE3f.js +1 -0
  12. package/dist/assets/doc-browser-DXCSd63E.js +1 -0
  13. package/dist/assets/{doc-browser-context-CuGI6PQc.js → doc-browser-context-FDPs3gJf.js} +1 -1
  14. package/dist/assets/{form-actions-BOfIyG3O.js → form-actions-0c0aw_bl.js} +1 -1
  15. package/dist/assets/i18n-provider-DkITJNz8.js +1 -0
  16. package/dist/assets/index-CQ2LcI2p.css +1 -0
  17. package/dist/assets/index-CrdfvBVg.js +107 -0
  18. package/dist/assets/loader-circle-DHtUxCIA.js +1 -0
  19. package/dist/assets/{mcp-marketplace-page-ofDqbOIF.js → mcp-marketplace-page-Bc_jI5iH.js} +2 -2
  20. package/dist/assets/mcp-marketplace-page-CzCieD9r.js +1 -0
  21. package/dist/assets/{model-config-page-D73Tu3jW.js → model-config-page-C4jw-pcj.js} +1 -1
  22. package/dist/assets/{navigation-link-CAFOcXNg.js → navigation-link-CVy0_kVl.js} +1 -1
  23. package/dist/assets/plus-CG1RwZQq.js +1 -0
  24. package/dist/assets/{provider-scoped-model-input-BaJJJYMX.js → provider-scoped-model-input-CmH4BFvc.js} +1 -1
  25. package/dist/assets/{providers-config-page-BL6IRVBm.js → providers-config-page-CJQjwzaw.js} +1 -1
  26. package/dist/assets/{react-BC_orpAj.js → react-DjFBVA0n.js} +1 -1
  27. package/dist/assets/{refresh-cw-CWDp_JnN.js → refresh-cw-j2bl79Ea.js} +1 -1
  28. package/dist/assets/remote-BIlpD3yL.js +1 -0
  29. package/dist/assets/rotate-cw-bKG8zH8M.js +1 -0
  30. package/dist/assets/runtime-config-page-B_x2Glz5.js +1 -0
  31. package/dist/assets/{save-Cnr71JON.js → save-BKNrtK81.js} +1 -1
  32. package/dist/assets/{search-CCX11X2m.js → search-CkOm65W_.js} +1 -1
  33. package/dist/assets/{search-config-page-BB6d5gT7.js → search-config-page-BaIijtmO.js} +1 -1
  34. package/dist/assets/{secrets-config-page-DYsDeT9K.js → secrets-config-page-CJjtdFAl.js} +2 -2
  35. package/dist/assets/security-config-DB8apj09.js +1 -0
  36. package/dist/assets/{select-DvwBu99K.js → select-Ea7ffKCE.js} +1 -1
  37. package/dist/assets/{settings-2-Dmv6zMne.js → settings-2-CtyN3m_V.js} +1 -1
  38. package/dist/assets/skeleton-Bt6zxcAB.js +1 -0
  39. package/dist/assets/{tag-chip-gMPvx7n_.js → tag-chip-C9l4xsKp.js} +1 -1
  40. package/dist/assets/x-CowtCu9Z.js +1 -0
  41. package/dist/index.html +20 -20
  42. package/package.json +8 -8
  43. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +30 -1
  44. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -1
  45. package/src/features/chat/features/ncp/hooks/__tests__/use-hydrated-ncp-agent.test.tsx +40 -3
  46. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.selection.test.tsx +66 -0
  47. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +13 -1
  48. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +573 -90
  49. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +389 -288
  50. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +54 -35
  51. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +79 -148
  52. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +149 -94
  53. package/src/features/chat/features/workspace/components/project-files/__tests__/workspace-directory-entry-menu.test.tsx +50 -0
  54. package/src/features/chat/features/workspace/components/project-files/workspace-directory-entry-menu.tsx +209 -0
  55. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +411 -0
  56. package/src/features/chat/features/workspace/components/project-files/workspace-new-folder-tree-item.tsx +112 -0
  57. package/src/features/chat/features/workspace/components/project-files/workspace-project-files-toolbar.tsx +64 -0
  58. package/src/features/chat/features/workspace/hooks/use-workspace-explorer-layout.ts +88 -0
  59. package/src/features/chat/features/workspace/hooks/use-workspace-file-actions.ts +335 -0
  60. package/src/features/chat/features/workspace/utils/chat-workspace-file-path-mutation.utils.ts +58 -0
  61. package/src/features/chat/features/workspace/utils/chat-workspace-file-tab.utils.ts +23 -22
  62. package/src/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils.ts +13 -4
  63. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +86 -60
  64. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +103 -99
  65. package/src/features/chat/managers/chat-composer-intent.manager.ts +37 -33
  66. package/src/features/chat/managers/chat-thread.manager.ts +132 -128
  67. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +20 -40
  68. package/src/features/chat/stores/chat-thread.store.ts +47 -74
  69. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +35 -38
  70. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +25 -19
  71. package/src/shared/components/ui/context-menu/__tests__/context-menu.test.tsx +60 -34
  72. package/src/shared/components/ui/context-menu/context-menu.tsx +95 -68
  73. package/src/shared/lib/api/managers/client.manager.ts +31 -17
  74. package/src/shared/lib/api/server-path/server-path.types.ts +66 -0
  75. package/src/shared/lib/api/types.ts +145 -116
  76. package/src/shared/lib/api/utils/server-path.utils.ts +35 -11
  77. package/src/shared/lib/i18n/locales/en-US/chat.json +42 -0
  78. package/src/shared/lib/i18n/locales/zh-CN/chat.json +42 -0
  79. package/dist/assets/appearance-settings-page-DpVJjvkB.js +0 -1
  80. package/dist/assets/chat-page-BuFDmG5_.js +0 -1
  81. package/dist/assets/doc-browser-CtO_HtRy.js +0 -1
  82. package/dist/assets/doc-browser-eMUagyBV.js +0 -1
  83. package/dist/assets/index-BjBdWHV1.js +0 -107
  84. package/dist/assets/index-DmpmjAGQ.css +0 -1
  85. package/dist/assets/loader-circle-Df8wF2Pr.js +0 -1
  86. package/dist/assets/mcp-marketplace-page-DI49IKxp.js +0 -1
  87. package/dist/assets/plus-DVU5RLCS.js +0 -1
  88. package/dist/assets/remote-T24O58lH.js +0 -1
  89. package/dist/assets/rotate-cw-BqhwSV5p.js +0 -1
  90. package/dist/assets/runtime-config-page-CXUx017t.js +0 -1
  91. package/dist/assets/security-config-CVPgqL7G.js +0 -1
  92. package/dist/assets/skeleton-C5tWata3.js +0 -1
  93. package/dist/assets/use-confirm-dialog-D_3-QpZU.js +0 -1
  94. package/dist/assets/x-BEWSqeux.js +0 -1
@@ -0,0 +1,112 @@
1
+ import { useLayoutEffect, useRef, type FormEvent } from 'react';
2
+ import { Check, File, Folder, X } from 'lucide-react';
3
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
4
+ import { Input } from '@/shared/components/ui/input';
5
+ import { t } from '@/shared/lib/i18n';
6
+
7
+ function isValidFolderName(value: string): boolean {
8
+ const name = value.trim();
9
+ return Boolean(name && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'));
10
+ }
11
+
12
+ export function WorkspaceNewFolderTreeItem({
13
+ error,
14
+ kind,
15
+ isCreating,
16
+ level,
17
+ name,
18
+ onCancel,
19
+ onNameChange,
20
+ onSubmit,
21
+ }: {
22
+ error: string | null;
23
+ kind: 'directory' | 'file';
24
+ isCreating: boolean;
25
+ level: number;
26
+ name: string;
27
+ onCancel: () => void;
28
+ onNameChange: (name: string) => void;
29
+ onSubmit: () => void;
30
+ }) {
31
+ const rowRef = useRef<HTMLFormElement | null>(null);
32
+ const inputRef = useRef<HTMLInputElement | null>(null);
33
+ const valid = isValidFolderName(name);
34
+
35
+ useLayoutEffect(() => {
36
+ const row = rowRef.current;
37
+ if (typeof row?.scrollIntoView === 'function') {
38
+ row.scrollIntoView({ block: 'nearest' });
39
+ }
40
+ inputRef.current?.focus();
41
+ inputRef.current?.select();
42
+ }, []);
43
+
44
+ const submit = (event: FormEvent) => {
45
+ event.preventDefault();
46
+ if (valid && !isCreating) {
47
+ onSubmit();
48
+ }
49
+ };
50
+
51
+ return (
52
+ <form
53
+ ref={rowRef}
54
+ role="treeitem"
55
+ aria-label={t(kind === 'file' ? 'chatWorkspaceNewFile' : 'chatWorkspaceNewFolder')}
56
+ className="bg-primary/5"
57
+ onSubmit={submit}
58
+ >
59
+ <div
60
+ className="flex min-h-8 min-w-0 items-center gap-1 py-0.5 pr-1"
61
+ style={{ paddingLeft: `${8 + level * 14}px` }}
62
+ >
63
+ <span className="inline-flex h-4 w-4 shrink-0" />
64
+ {kind === 'file' ? (
65
+ <File className="h-4 w-4 shrink-0 text-gray-500" />
66
+ ) : (
67
+ <Folder className="h-4 w-4 shrink-0 text-amber-500" />
68
+ )}
69
+ <Input
70
+ ref={inputRef}
71
+ aria-label={t(kind === 'file' ? 'chatWorkspaceFileName' : 'chatWorkspaceFolderName')}
72
+ className="h-7 min-w-0 flex-1 rounded-md px-2 text-xs"
73
+ disabled={isCreating}
74
+ placeholder={t(kind === 'file' ? 'chatWorkspaceFileNamePlaceholder' : 'chatWorkspaceFolderNamePlaceholder')}
75
+ value={name}
76
+ onChange={(event) => onNameChange(event.target.value)}
77
+ onKeyDown={(event) => {
78
+ if (event.key === 'Escape') {
79
+ event.preventDefault();
80
+ onCancel();
81
+ }
82
+ }}
83
+ />
84
+ <IconActionButton
85
+ disabled={!valid || isCreating}
86
+ icon={<Check className="h-3.5 w-3.5" />}
87
+ label={t('chatWorkspaceCreateFolder')}
88
+ size="sm"
89
+ tooltipSide="top"
90
+ onClick={onSubmit}
91
+ />
92
+ <IconActionButton
93
+ disabled={isCreating}
94
+ icon={<X className="h-3.5 w-3.5" />}
95
+ label={t('cancel')}
96
+ size="sm"
97
+ tooltipSide="top"
98
+ onClick={onCancel}
99
+ />
100
+ </div>
101
+ {error ? (
102
+ <p
103
+ role="alert"
104
+ className="pb-1 pr-2 text-[11px] text-destructive"
105
+ style={{ paddingLeft: `${42 + level * 14}px` }}
106
+ >
107
+ {error}
108
+ </p>
109
+ ) : null}
110
+ </form>
111
+ );
112
+ }
@@ -0,0 +1,64 @@
1
+ import { ChevronsUp, FilePlus2, FolderPlus, MoreHorizontal, RefreshCw } from 'lucide-react';
2
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
3
+ import { ContextMenuTrigger } from '@/shared/components/ui/context-menu/context-menu';
4
+ import { t } from '@/shared/lib/i18n';
5
+
6
+ export function WorkspaceProjectFilesToolbar({
7
+ disabled,
8
+ rootLabel,
9
+ onCollapseAll,
10
+ onNewFile,
11
+ onNewFolder,
12
+ onRefresh,
13
+ }: {
14
+ disabled: boolean;
15
+ rootLabel: string;
16
+ onCollapseAll: () => void;
17
+ onNewFile: () => void;
18
+ onNewFolder: () => void;
19
+ onRefresh: () => void;
20
+ }) {
21
+ return (
22
+ <div className="flex h-9 shrink-0 items-center gap-0.5 border-b border-gray-200/80 bg-gray-50/55 px-1.5">
23
+ <span
24
+ className="min-w-0 flex-1 truncate px-0.5 text-[11px] font-semibold uppercase tracking-[0.03em] text-gray-800"
25
+ title={rootLabel}
26
+ >
27
+ {rootLabel}
28
+ </span>
29
+ <IconActionButton
30
+ icon={<FilePlus2 className="h-4 w-4" />}
31
+ label={t('chatWorkspaceNewFile')}
32
+ size="sm"
33
+ disabled={disabled}
34
+ onClick={onNewFile}
35
+ />
36
+ <IconActionButton
37
+ icon={<FolderPlus className="h-4 w-4" />}
38
+ label={t('chatWorkspaceNewFolder')}
39
+ size="sm"
40
+ disabled={disabled}
41
+ onClick={onNewFolder}
42
+ />
43
+ <IconActionButton
44
+ icon={<RefreshCw className="h-4 w-4" />}
45
+ label={t('chatWorkspaceRefreshExplorer')}
46
+ size="sm"
47
+ onClick={onRefresh}
48
+ />
49
+ <IconActionButton
50
+ icon={<ChevronsUp className="h-4 w-4" />}
51
+ label={t('chatWorkspaceCollapseAll')}
52
+ size="sm"
53
+ onClick={onCollapseAll}
54
+ />
55
+ <ContextMenuTrigger>
56
+ <IconActionButton
57
+ icon={<MoreHorizontal className="h-4 w-4" />}
58
+ label={t('chatWorkspaceRootMoreActions')}
59
+ size="sm"
60
+ />
61
+ </ContextMenuTrigger>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,88 @@
1
+ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
2
+ import { usePresenter } from '@/features/chat/components/providers/chat-presenter.provider';
3
+ import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
4
+ import {
5
+ CHAT_WORKSPACE_EXPLORER_COMPACT_THRESHOLD,
6
+ normalizeChatWorkspaceExplorerWidth,
7
+ } from '@/features/chat/features/workspace/utils/chat-workspace-panel-layout.utils';
8
+
9
+ export function useWorkspaceExplorerLayout({
10
+ fileActive,
11
+ projectFilesActive,
12
+ sessionProjectRoot,
13
+ sessionWorkingDir,
14
+ }: {
15
+ fileActive: boolean;
16
+ projectFilesActive: boolean;
17
+ sessionProjectRoot: string | null;
18
+ sessionWorkingDir: string | null;
19
+ }) {
20
+ const presenter = usePresenter();
21
+ const containerRef = useRef<HTMLDivElement | null>(null);
22
+ const resizeRef = useRef<{ startX: number; startWidth: number; width: number } | null>(null);
23
+ const persistedExplorerWidth = useChatThreadStore((state) => state.snapshot.workspaceExplorerWidth);
24
+ const [compact, setCompact] = useState(false);
25
+ const [explorerDragWidth, setExplorerDragWidth] = useState<number | null>(null);
26
+ const [explorerOpen, setExplorerOpen] = useState(true);
27
+ const explorerWidth = explorerDragWidth ?? persistedExplorerWidth;
28
+
29
+ useEffect(() => {
30
+ const node = containerRef.current;
31
+ if (!node || typeof ResizeObserver === 'undefined') return;
32
+ const update = () => setCompact(node.getBoundingClientRect().width < CHAT_WORKSPACE_EXPLORER_COMPACT_THRESHOLD);
33
+ update();
34
+ const observer = new ResizeObserver(update);
35
+ observer.observe(node);
36
+ return () => observer.disconnect();
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ setExplorerOpen(true);
41
+ }, [sessionProjectRoot, sessionWorkingDir]);
42
+
43
+ useEffect(() => {
44
+ if (!fileActive || !compact || !explorerOpen) return;
45
+ const closeOnEscape = (event: KeyboardEvent) => {
46
+ if (event.key === 'Escape') setExplorerOpen(false);
47
+ };
48
+ window.addEventListener('keydown', closeOnEscape);
49
+ return () => window.removeEventListener('keydown', closeOnEscape);
50
+ }, [compact, explorerOpen, fileActive]);
51
+
52
+ const onResizeStart = (event: ReactPointerEvent<HTMLDivElement>) => {
53
+ if (compact || !fileActive) return;
54
+ event.preventDefault();
55
+ event.currentTarget.setPointerCapture?.(event.pointerId);
56
+ resizeRef.current = { startX: event.clientX, startWidth: explorerWidth, width: explorerWidth };
57
+ const onMove = (moveEvent: PointerEvent) => {
58
+ const resizing = resizeRef.current;
59
+ if (!resizing) return;
60
+ const width = normalizeChatWorkspaceExplorerWidth(resizing.startWidth + moveEvent.clientX - resizing.startX);
61
+ resizing.width = width;
62
+ setExplorerDragWidth(width);
63
+ };
64
+ const onUp = () => {
65
+ const width = resizeRef.current?.width;
66
+ if (typeof width === 'number') presenter.chatThreadManager.setWorkspaceExplorerWidth(width);
67
+ setExplorerDragWidth(null);
68
+ resizeRef.current = null;
69
+ window.removeEventListener('pointermove', onMove);
70
+ window.removeEventListener('pointerup', onUp);
71
+ window.removeEventListener('pointercancel', onUp);
72
+ };
73
+ window.addEventListener('pointermove', onMove);
74
+ window.addEventListener('pointerup', onUp);
75
+ window.addEventListener('pointercancel', onUp);
76
+ };
77
+
78
+ return {
79
+ compact,
80
+ containerRef,
81
+ explorerOpen,
82
+ explorerOverlay: fileActive && compact && explorerOpen,
83
+ explorerWidth,
84
+ onResizeStart,
85
+ setExplorerOpen,
86
+ showExplorer: projectFilesActive || (fileActive && explorerOpen),
87
+ };
88
+ }
@@ -0,0 +1,335 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { NextClawClientError } from '@nextclaw/client-sdk';
3
+ import { toast } from 'sonner';
4
+ import type { useConfirmDialog } from '@/shared/hooks/use-confirm-dialog';
5
+ import {
6
+ createServerPathDirectory,
7
+ createServerPathFile,
8
+ deleteServerPathEntry,
9
+ renameServerPathEntry,
10
+ uploadServerPathFiles,
11
+ type ServerPathEntryView,
12
+ } from '@/shared/lib/api';
13
+ import { t } from '@/shared/lib/i18n';
14
+
15
+ type WorkspaceFilePendingAction = 'create-directory' | 'create-file' | 'delete' | 'rename' | 'upload';
16
+ type Confirm = ReturnType<typeof useConfirmDialog>['confirm'];
17
+
18
+ export type RefreshDirectory = () => Promise<unknown>;
19
+
20
+ export type WorkspaceDirectoryActionTarget = {
21
+ label: string;
22
+ path: string;
23
+ refresh: RefreshDirectory;
24
+ expand?: () => void;
25
+ };
26
+
27
+ export type WorkspaceTreeSelection = {
28
+ path: string;
29
+ createTarget: WorkspaceDirectoryActionTarget;
30
+ };
31
+
32
+ function readErrorMessage(error: unknown): string {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+
36
+ function replaceCount(text: string, count: number): string {
37
+ return text.replace('{count}', String(count));
38
+ }
39
+
40
+ function useWorkspaceFileActions() {
41
+ const [pendingAction, setPendingAction] = useState<WorkspaceFilePendingAction | null>(null);
42
+ const pendingActionRef = useRef<WorkspaceFilePendingAction | null>(null);
43
+
44
+ const run = async <T>(action: WorkspaceFilePendingAction, operation: () => Promise<T>): Promise<T> => {
45
+ if (pendingActionRef.current) {
46
+ throw new Error('another workspace file operation is already running');
47
+ }
48
+ pendingActionRef.current = action;
49
+ setPendingAction(action);
50
+ try {
51
+ return await operation();
52
+ } finally {
53
+ pendingActionRef.current = null;
54
+ setPendingAction(null);
55
+ }
56
+ };
57
+
58
+ return {
59
+ pendingAction,
60
+ createDirectory: (params: { basePath: string; parentPath: string; name: string }) =>
61
+ run('create-directory', () => createServerPathDirectory(params)),
62
+ createFile: (params: { basePath: string; parentPath: string; name: string }) =>
63
+ run('create-file', () => createServerPathFile(params)),
64
+ deleteEntry: (params: { basePath: string; path: string }) => run('delete', () => deleteServerPathEntry(params)),
65
+ renameEntry: (params: { basePath: string; path: string; name: string }) =>
66
+ run('rename', () => renameServerPathEntry(params)),
67
+ uploadFiles: (params: { basePath: string; targetPath: string; files: readonly File[]; overwrite?: boolean }) =>
68
+ run('upload', () => uploadServerPathFiles(params)),
69
+ };
70
+ }
71
+
72
+ type WorkspaceFileActions = ReturnType<typeof useWorkspaceFileActions>;
73
+
74
+ async function uploadWorkspaceFiles({
75
+ actions,
76
+ confirm,
77
+ files,
78
+ rootPath,
79
+ target,
80
+ }: {
81
+ actions: WorkspaceFileActions;
82
+ confirm: Confirm;
83
+ files: readonly File[];
84
+ rootPath: string;
85
+ target: WorkspaceDirectoryActionTarget;
86
+ }) {
87
+ try {
88
+ let result;
89
+ try {
90
+ result = await actions.uploadFiles({
91
+ basePath: rootPath,
92
+ targetPath: target.path,
93
+ files,
94
+ });
95
+ } catch (error) {
96
+ if (!(error instanceof NextClawClientError) || error.code !== 'SERVER_PATH_FILE_EXISTS') {
97
+ throw error;
98
+ }
99
+ const conflicts = Array.isArray(error.details?.conflicts) ? error.details.conflicts.length : files.length;
100
+ const shouldReplace = await confirm({
101
+ title: t('chatWorkspaceUploadConflictTitle'),
102
+ description: replaceCount(t('chatWorkspaceUploadConflictDescription'), conflicts),
103
+ confirmLabel: t('chatWorkspaceReplaceFiles'),
104
+ variant: 'destructive',
105
+ });
106
+ if (!shouldReplace) return;
107
+ result = await actions.uploadFiles({
108
+ basePath: rootPath,
109
+ targetPath: target.path,
110
+ files,
111
+ overwrite: true,
112
+ });
113
+ }
114
+ await target.refresh();
115
+ toast.success(replaceCount(t('chatWorkspaceUploadSucceeded'), result.files.length));
116
+ } catch (error) {
117
+ toast.error(`${t('chatWorkspaceUploadFailed')}: ${readErrorMessage(error)}`);
118
+ }
119
+ }
120
+
121
+ async function deleteWorkspaceEntry({
122
+ actions,
123
+ confirm,
124
+ entry,
125
+ refresh,
126
+ rootPath,
127
+ }: {
128
+ actions: WorkspaceFileActions;
129
+ confirm: Confirm;
130
+ entry: ServerPathEntryView;
131
+ refresh: RefreshDirectory;
132
+ rootPath: string;
133
+ }) {
134
+ const isDirectory = entry.kind === 'directory';
135
+ const confirmed = await confirm({
136
+ title: t(isDirectory ? 'chatWorkspaceDeleteFolderTitle' : 'chatWorkspaceDeleteFileTitle').replace(
137
+ '{name}',
138
+ entry.name,
139
+ ),
140
+ description: t(isDirectory ? 'chatWorkspaceDeleteFolderDescription' : 'chatWorkspaceDeleteFileDescription'),
141
+ confirmLabel: t('chatWorkspaceDelete'),
142
+ variant: 'destructive',
143
+ });
144
+ if (!confirmed) return;
145
+ const toastId = toast.loading(t('chatWorkspaceDeleting').replace('{name}', entry.name));
146
+ try {
147
+ const deleted = await actions.deleteEntry({
148
+ basePath: rootPath,
149
+ path: entry.path,
150
+ });
151
+ await refresh();
152
+ toast.dismiss(toastId);
153
+ toast.success(t('chatWorkspaceDeleteSucceeded').replace('{name}', entry.name));
154
+ return deleted;
155
+ } catch (error) {
156
+ toast.dismiss(toastId);
157
+ toast.error(`${t('chatWorkspaceDeleteFailed').replace('{name}', entry.name)}: ${readErrorMessage(error)}`);
158
+ return null;
159
+ }
160
+ }
161
+
162
+ async function createWorkspaceEntry({
163
+ actions,
164
+ kind,
165
+ name,
166
+ rootPath,
167
+ setError,
168
+ target,
169
+ }: {
170
+ actions: WorkspaceFileActions;
171
+ kind: 'directory' | 'file';
172
+ name: string;
173
+ rootPath: string;
174
+ setError: (error: string | null) => void;
175
+ target: WorkspaceDirectoryActionTarget;
176
+ }) {
177
+ setError(null);
178
+ try {
179
+ const created = await (kind === 'file' ? actions.createFile : actions.createDirectory)({
180
+ basePath: rootPath,
181
+ parentPath: target.path,
182
+ name,
183
+ });
184
+ await target.refresh();
185
+ toast.success(t(kind === 'file' ? 'chatWorkspaceFileCreated' : 'chatWorkspaceFolderCreated'));
186
+ return created;
187
+ } catch (error) {
188
+ setError(
189
+ `${t(kind === 'file' ? 'chatWorkspaceFileCreateFailed' : 'chatWorkspaceFolderCreateFailed')}: ${readErrorMessage(error)}`,
190
+ );
191
+ return null;
192
+ }
193
+ }
194
+
195
+ type WorkspaceProjectFilesControllerOptions = {
196
+ confirm: Confirm;
197
+ onEntryDeleted?: (path: string) => void;
198
+ onEntryRenamed?: (params: { previousPath: string; path: string; label: string }) => void;
199
+ rootPath: string | null;
200
+ };
201
+
202
+ export function useWorkspaceProjectFilesController({
203
+ confirm,
204
+ onEntryDeleted,
205
+ onEntryRenamed,
206
+ rootPath,
207
+ }: WorkspaceProjectFilesControllerOptions) {
208
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
209
+ const actions = useWorkspaceFileActions();
210
+ const [createTarget, setCreateTarget] = useState<WorkspaceDirectoryActionTarget | null>(null);
211
+ const [createName, setCreateName] = useState('');
212
+ const [createKind, setCreateKind] = useState<'directory' | 'file'>('directory');
213
+ const [createError, setCreateError] = useState<string | null>(null);
214
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
215
+ const [selectedCreateTarget, setSelectedCreateTarget] = useState<WorkspaceDirectoryActionTarget | null>(null);
216
+ const [revealPath, setRevealPath] = useState<string | null>(null);
217
+ const [uploadTarget, setUploadTarget] = useState<WorkspaceDirectoryActionTarget | null>(null);
218
+
219
+ useEffect(() => {
220
+ setCreateTarget(null);
221
+ setSelectedPath(null);
222
+ setSelectedCreateTarget(null);
223
+ setRevealPath(null);
224
+ setUploadTarget(null);
225
+ }, [rootPath]);
226
+
227
+ const requestUpload = (target: WorkspaceDirectoryActionTarget) => {
228
+ setUploadTarget(target);
229
+ if (!fileInputRef.current) return;
230
+ fileInputRef.current.value = '';
231
+ fileInputRef.current.click();
232
+ };
233
+ const selectEntry = (selection: WorkspaceTreeSelection) => {
234
+ setSelectedPath(selection.path);
235
+ setSelectedCreateTarget(selection.createTarget);
236
+ };
237
+ const openCreate = (kind: 'directory' | 'file', target: WorkspaceDirectoryActionTarget) => {
238
+ target.expand?.();
239
+ setCreateKind(kind);
240
+ setCreateName('');
241
+ setCreateError(null);
242
+ setCreateTarget(target);
243
+ };
244
+ const uploadFiles = async (files: readonly File[]) => {
245
+ if (!rootPath || !uploadTarget || files.length === 0) return;
246
+ await uploadWorkspaceFiles({
247
+ actions,
248
+ confirm,
249
+ files,
250
+ rootPath,
251
+ target: uploadTarget,
252
+ });
253
+ };
254
+ const deleteEntry = async (entry: ServerPathEntryView, refresh: RefreshDirectory) => {
255
+ if (!rootPath) return;
256
+ const deleted = await deleteWorkspaceEntry({
257
+ actions,
258
+ confirm,
259
+ entry,
260
+ refresh,
261
+ rootPath,
262
+ });
263
+ if (deleted) onEntryDeleted?.(deleted.path);
264
+ };
265
+ const renameEntry = async (entry: ServerPathEntryView, name: string, refresh: RefreshDirectory) => {
266
+ if (!rootPath) return null;
267
+ const renamed = await actions.renameEntry({
268
+ basePath: rootPath,
269
+ path: entry.path,
270
+ name,
271
+ });
272
+ onEntryRenamed?.({
273
+ previousPath: renamed.oldPath,
274
+ path: renamed.path,
275
+ label: renamed.name,
276
+ });
277
+ await refresh();
278
+ setSelectedPath(renamed.path);
279
+ setRevealPath(renamed.path);
280
+ toast.success(t('chatWorkspaceRenameSucceeded').replace('{name}', renamed.name));
281
+ return renamed;
282
+ };
283
+ const submitCreate = async () => {
284
+ if (!rootPath || !createTarget) return;
285
+ const created = await createWorkspaceEntry({
286
+ actions,
287
+ kind: createKind,
288
+ name: createName,
289
+ rootPath,
290
+ setError: setCreateError,
291
+ target: createTarget,
292
+ });
293
+ if (!created) return;
294
+ setCreateTarget(null);
295
+ setSelectedPath(created.path);
296
+ setSelectedCreateTarget({
297
+ label: createName.trim(),
298
+ path: created.path,
299
+ refresh: createTarget.refresh,
300
+ });
301
+ setRevealPath(created.path);
302
+ };
303
+
304
+ return {
305
+ actions,
306
+ cancelCreateFolder: () => {
307
+ setCreateTarget(null);
308
+ setCreateName('');
309
+ setCreateError(null);
310
+ },
311
+ clearSelection: () => {
312
+ setSelectedPath(null);
313
+ setSelectedCreateTarget(null);
314
+ },
315
+ completeReveal: () => setRevealPath(null),
316
+ submitCreate,
317
+ createError,
318
+ createName,
319
+ createKind,
320
+ createTarget,
321
+ deleteEntry,
322
+ fileInputRef,
323
+ openCreateFile: (target: WorkspaceDirectoryActionTarget) => openCreate('file', target),
324
+ openCreateFolder: (target: WorkspaceDirectoryActionTarget) => openCreate('directory', target),
325
+ requestUpload,
326
+ renameEntry,
327
+ revealPath,
328
+ selectEntry,
329
+ selectedCreateTarget,
330
+ selectedPath,
331
+ setCreateError,
332
+ setCreateName,
333
+ uploadFiles,
334
+ };
335
+ }
@@ -0,0 +1,58 @@
1
+ import type { ChatThreadSnapshot, ChatWorkspaceNavigationEntry } from '@/features/chat/stores/chat-thread.store';
2
+ import { closeWorkspaceTabSnapshot } from '@/features/chat/features/workspace/utils/chat-thread-workspace-session.utils';
3
+ import { replaceWorkspaceFileTabPath } from '@/features/chat/features/workspace/utils/chat-workspace-file-tab.utils';
4
+
5
+ function replaceWorkspacePathPrefix(candidate: string, previousPath: string, nextPath: string): string | null {
6
+ if (candidate === previousPath) return nextPath;
7
+ if (candidate.startsWith(`${previousPath}/`) || candidate.startsWith(`${previousPath}\\`)) {
8
+ return `${nextPath}${candidate.slice(previousPath.length)}`;
9
+ }
10
+ return null;
11
+ }
12
+
13
+ export function createRenamedWorkspacePathPatch(
14
+ snapshot: ChatThreadSnapshot,
15
+ params: { previousPath: string; path: string; label: string },
16
+ ): Partial<ChatThreadSnapshot> | null {
17
+ const previousPath = params.previousPath.trim();
18
+ const path = params.path.trim();
19
+ if (!previousPath || !path || previousPath === path) return null;
20
+
21
+ const keyMap = new Map<string, string>();
22
+ const workspaceFileTabs = snapshot.workspaceFileTabs.map((tab) => {
23
+ const nextPath = replaceWorkspacePathPrefix(tab.path, previousPath, path);
24
+ if (!nextPath) return tab;
25
+ const nextTab = replaceWorkspaceFileTabPath(tab, nextPath, tab.path === previousPath ? params.label : tab.label);
26
+ keyMap.set(tab.key, nextTab.key);
27
+ return nextTab;
28
+ });
29
+ if (keyMap.size === 0) return null;
30
+
31
+ const remapEntry = (entry: ChatWorkspaceNavigationEntry): ChatWorkspaceNavigationEntry =>
32
+ entry.kind === 'file' && keyMap.has(entry.key) ? { kind: 'file', key: keyMap.get(entry.key)! } : entry;
33
+ return {
34
+ workspaceFileTabs,
35
+ activeWorkspaceFileKey: snapshot.activeWorkspaceFileKey
36
+ ? (keyMap.get(snapshot.activeWorkspaceFileKey) ?? snapshot.activeWorkspaceFileKey)
37
+ : null,
38
+ closedWorkspaceTabEntries: snapshot.closedWorkspaceTabEntries.map(remapEntry),
39
+ workspaceNavigationHistory: snapshot.workspaceNavigationHistory.map(remapEntry),
40
+ };
41
+ }
42
+
43
+ export function createRemovedWorkspacePathSnapshot(
44
+ snapshot: ChatThreadSnapshot,
45
+ path: string,
46
+ ): ChatThreadSnapshot | null {
47
+ const normalizedPath = path.trim();
48
+ if (!normalizedPath) return null;
49
+ let nextSnapshot = snapshot;
50
+ const matchingKeys = snapshot.workspaceFileTabs
51
+ .filter((tab) => replaceWorkspacePathPrefix(tab.path, normalizedPath, normalizedPath) !== null)
52
+ .map((tab) => tab.key);
53
+ for (const key of matchingKeys) {
54
+ const patch = closeWorkspaceTabSnapshot(nextSnapshot, { kind: 'file', key });
55
+ if (patch) nextSnapshot = { ...nextSnapshot, ...patch };
56
+ }
57
+ return matchingKeys.length > 0 ? nextSnapshot : null;
58
+ }