Skip to content

Commit 86ed365

Browse files
limeburstclaude
andcommitted
control-plane: Move save button to filename header bar
Use forwardRef/useImperativeHandle to expose a save method from FileViewer, and place the save button in the filename header bar of both FileExplorer and FileExplorerWithSelected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ed8ad68 commit 86ed365

File tree

3 files changed

+98
-103
lines changed

3 files changed

+98
-103
lines changed

control-plane/src/components/browser/FileExplorer.tsx

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import { useState, useRef } from "react";
44
import DirectoryTree from "./DirectoryTree";
5-
import FileViewer from "./FileViewer";
5+
import FileViewer, { FileViewerRef } from "./FileViewer";
66
import { FileNode } from "@/lib/fileUtils";
77
import { EDITABLE_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS, AUDIO_FILE_EXTENSIONS } from "@/lib/const";
88
import { toast } from "sonner";
9+
import { Button } from "@/components/ui/button";
910

1011
interface FileExplorerProps {
1112
initialFiles: FileNode[];
@@ -57,6 +58,7 @@ export default function FileExplorer({ initialFiles, userLoginName }: FileExplor
5758
const [isRenaming, setIsRenaming] = useState(false);
5859
const [newFileName, setNewFileName] = useState("");
5960
const dragCounterRef = useRef(0);
61+
const fileViewerRef = useRef<FileViewerRef>(null);
6062

6163
const handleFileSelect = (filePath: string, isDirectory: boolean) => {
6264
if (isDirectory) {
@@ -384,49 +386,54 @@ export default function FileExplorer({ initialFiles, userLoginName }: FileExplor
384386
</div>
385387
</div>
386388
) : (
387-
<div className="flex items-center space-x-2 flex-1">
388-
{(() => {
389-
// Check if selected item is a directory
390-
const findNode = (nodes: FileNode[], path: string): FileNode | null => {
391-
for (const node of nodes) {
392-
if (node.path === path) return node;
393-
if (node.children) {
394-
const found = findNode(node.children, path);
395-
if (found) return found;
389+
<>
390+
<div className="flex items-center space-x-2 flex-1">
391+
{(() => {
392+
// Check if selected item is a directory
393+
const findNode = (nodes: FileNode[], path: string): FileNode | null => {
394+
for (const node of nodes) {
395+
if (node.path === path) return node;
396+
if (node.children) {
397+
const found = findNode(node.children, path);
398+
if (found) return found;
399+
}
396400
}
401+
return null;
402+
};
403+
404+
const selectedNode = findNode(files, selectedFile);
405+
const isDirectory = selectedNode?.isDirectory;
406+
407+
if (isDirectory) {
408+
return (
409+
<h3 className="font-medium text-foreground truncate">
410+
{getFileIcon(selectedFile.split('/').pop() || "", true)} {selectedFile.split('/').pop() || "루트"} (작업 폴더)
411+
</h3>
412+
);
413+
} else {
414+
return (
415+
<h3
416+
className="font-medium text-foreground truncate cursor-pointer hover:text-primary transition-colors"
417+
onClick={handleStartRename}
418+
title="클릭하여 이름 변경"
419+
>
420+
{getFileIcon(selectedFile.split('/').pop() || "", false)} {selectedFile.split('/').pop()}
421+
</h3>
422+
);
397423
}
398-
return null;
399-
};
400-
401-
const selectedNode = findNode(files, selectedFile);
402-
const isDirectory = selectedNode?.isDirectory;
403-
404-
if (isDirectory) {
405-
return (
406-
<h3 className="font-medium text-foreground truncate">
407-
{getFileIcon(selectedFile.split('/').pop() || "", true)} {selectedFile.split('/').pop() || "루트"} (작업 폴더)
408-
</h3>
409-
);
410-
} else {
411-
return (
412-
<h3
413-
className="font-medium text-foreground truncate cursor-pointer hover:text-primary transition-colors"
414-
onClick={handleStartRename}
415-
title="클릭하여 이름 변경"
416-
>
417-
{getFileIcon(selectedFile.split('/').pop() || "", false)} {selectedFile.split('/').pop()}
418-
</h3>
419-
);
420-
}
421-
})()}
422-
</div>
424+
})()}
425+
</div>
426+
<Button size="sm" onClick={() => fileViewerRef.current?.save()}>
427+
저장
428+
</Button>
429+
</>
423430
)}
424431
</div>
425432
) : (
426433
<h3 className="font-medium text-foreground">파일을 선택하세요</h3>
427434
)}
428435
</div>
429-
<div className="flex-1 overflow-hidden">
436+
<div className="flex-1 overflow-auto">
430437
{selectedFile ? (
431438
(() => {
432439
// Check if selected item is a directory
@@ -454,7 +461,7 @@ export default function FileExplorer({ initialFiles, userLoginName }: FileExplor
454461
</div>
455462
);
456463
} else {
457-
return <FileViewer filePath={selectedFile} userLoginName={userLoginName} />;
464+
return <FileViewer ref={fileViewerRef} filePath={selectedFile} userLoginName={userLoginName} />;
458465
}
459466
})()
460467
) : (

control-plane/src/components/browser/FileExplorerWithSelected.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useState, useRef } from "react";
44
import DirectoryTree from "./DirectoryTree";
5-
import FileViewer from "./FileViewer";
5+
import FileViewer, { FileViewerRef } from "./FileViewer";
66
import { FileNode } from "@/lib/fileUtils";
7+
import { Button } from "@/components/ui/button";
78

89
interface FileExplorerWithSelectedProps {
910
initialFiles: FileNode[];
@@ -18,7 +19,8 @@ export default function FileExplorerWithSelected({
1819
}: FileExplorerWithSelectedProps) {
1920
const [files, setFiles] = useState<FileNode[]>(initialFiles);
2021
const [selectedFile, setSelectedFile] = useState<string | null>(initialSelectedFile);
21-
22+
const fileViewerRef = useRef<FileViewerRef>(null);
23+
2224
// Build expanded folders set based on the initially selected file
2325
const buildInitialExpandedFolders = (selectedFilePath: string | null): Set<string> => {
2426
const expanded = new Set<string>([""]); // Always expand root
@@ -93,14 +95,19 @@ export default function FileExplorerWithSelected({
9395

9496
{/* Right Main Area - File Viewer */}
9597
<div className="flex-1 flex flex-col">
96-
<div className="p-3 border-b border-border bg-secondary">
98+
<div className="p-3 border-b border-border bg-secondary flex items-center justify-between">
9799
<h3 className="font-medium text-foreground">
98100
{selectedFile ? `📄 ${selectedFile.split('/').pop()}` : "파일을 선택하세요"}
99101
</h3>
102+
{selectedFile && (
103+
<Button size="sm" onClick={() => fileViewerRef.current?.save()}>
104+
저장
105+
</Button>
106+
)}
100107
</div>
101-
<div className="flex-1 overflow-hidden">
108+
<div className="flex-1 overflow-auto">
102109
{selectedFile ? (
103-
<FileViewer filePath={selectedFile} userLoginName={userLoginName} />
110+
<FileViewer ref={fileViewerRef} filePath={selectedFile} userLoginName={userLoginName} />
104111
) : (
105112
<div className="flex items-center justify-center h-full text-muted-foreground">
106113
<div className="text-center">

control-plane/src/components/browser/FileViewer.tsx

Lines changed: 41 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,23 @@
11
"use client";
22

3-
import { useState, useEffect, createContext, useContext } from "react";
3+
import { useState, useEffect, useImperativeHandle, forwardRef } from "react";
44
import { EDITABLE_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS, AUDIO_FILE_EXTENSIONS } from "@/lib/const";
55
import { getPublicAssetUrl } from "@/lib/utils";
66
import Editor from "@/components/Editor";
77
import ImageViewer from "./ImageViewer";
88
import { Button } from "@/components/ui/button";
99
import { toast } from "sonner";
1010

11-
const EditorContext = createContext<{
12-
value: string;
13-
setValue: (value: string) => void;
14-
} | null>(null);
15-
16-
function SaveButton({ filename }: { filename: string }) {
17-
const context = useContext(EditorContext);
18-
if (!context) return null;
19-
20-
const { value } = context;
21-
22-
const handleSave = async () => {
23-
try {
24-
const response = await fetch("/api/files/save", {
25-
method: "POST",
26-
headers: {
27-
"Content-Type": "application/json",
28-
},
29-
body: JSON.stringify({ filename, contents: value }),
30-
});
31-
32-
const res = await response.json();
33-
if (res.success) {
34-
toast.success(`${res.message}: ${filename}`);
35-
} else {
36-
toast.error(`저장 실패: ${res.message}`);
37-
}
38-
} catch (error) {
39-
toast.error(`파일 저장에 실패했습니다: ${filename}`);
40-
}
41-
};
42-
43-
return (
44-
<Button
45-
type="button"
46-
onClick={handleSave}
47-
className="w-full"
48-
>
49-
저장
50-
</Button>
51-
);
11+
export interface FileViewerRef {
12+
save: () => Promise<void>;
5213
}
5314

5415
interface FileViewerProps {
5516
filePath: string;
5617
userLoginName: string;
5718
}
5819

59-
export default function FileViewer({ filePath, userLoginName }: FileViewerProps) {
20+
const FileViewer = forwardRef<FileViewerRef, FileViewerProps>(function FileViewer({ filePath, userLoginName }, ref) {
6021
const [fileContent, setFileContent] = useState<string>("");
6122
const [loading, setLoading] = useState(true);
6223
const [error, setError] = useState<string | null>(null);
@@ -122,25 +83,43 @@ export default function FileViewer({ filePath, userLoginName }: FileViewerProps)
12283
);
12384
}
12485

86+
const handleSave = async () => {
87+
try {
88+
const response = await fetch("/api/files/save", {
89+
method: "POST",
90+
headers: {
91+
"Content-Type": "application/json",
92+
},
93+
body: JSON.stringify({ filename: filePath, contents: editorValue }),
94+
});
95+
96+
const res = await response.json();
97+
if (res.success) {
98+
toast.success(`${res.message}: ${filePath}`);
99+
} else {
100+
toast.error(`저장 실패: ${res.message}`);
101+
}
102+
} catch (error) {
103+
toast.error(`파일 저장에 실패했습니다: ${filePath}`);
104+
}
105+
};
106+
107+
useImperativeHandle(ref, () => ({
108+
save: handleSave,
109+
}), [editorValue, filePath]);
110+
125111
// Handle different file types
126112
if (isEditable) {
127113
return (
128-
<EditorContext.Provider value={{ value: editorValue, setValue: setEditorValue }}>
129-
<div className="h-full flex flex-col">
130-
<div className="shrink-0 p-2 border-b border-border bg-muted">
131-
<SaveButton filename={filePath} />
132-
</div>
133-
<div className="flex-1 min-h-0 overflow-auto p-4">
134-
<Editor
135-
filename={filePath}
136-
contents={fileContent}
137-
showSaveButton={false}
138-
value={editorValue}
139-
onChange={setEditorValue}
140-
/>
141-
</div>
142-
</div>
143-
</EditorContext.Provider>
114+
<div className="h-full overflow-auto p-4">
115+
<Editor
116+
filename={filePath}
117+
contents={fileContent}
118+
showSaveButton={false}
119+
value={editorValue}
120+
onChange={setEditorValue}
121+
/>
122+
</div>
144123
);
145124
}
146125

@@ -205,4 +184,6 @@ export default function FileViewer({ filePath, userLoginName }: FileViewerProps)
205184
</div>
206185
</div>
207186
);
208-
}
187+
});
188+
189+
export default FileViewer;

0 commit comments

Comments
 (0)