Config editor rename to file manager + fixed up file manager UI
This commit is contained in:
8
src/ui/apps/File Manager/FIleManagerTopNavbar.tsx
Normal file
8
src/ui/apps/File Manager/FIleManagerTopNavbar.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
||||
|
||||
export function FIleManagerTopNavbar(props: any): React.ReactElement {
|
||||
return (
|
||||
<FileManagerTabList {...props} />
|
||||
)
|
||||
}
|
||||
621
src/ui/apps/File Manager/FileManager.tsx
Normal file
621
src/ui/apps/File Manager/FileManager.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
import React, {useState, useEffect, useRef} from "react";
|
||||
import {FileManagerLeftSidebar} from "@/ui/apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx";
|
||||
import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx";
|
||||
import {FileManagerFileEditor} from "@/ui/apps/File Manager/FileManagerFileEditor.tsx";
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {
|
||||
getConfigEditorRecent,
|
||||
getConfigEditorPinned,
|
||||
getConfigEditorShortcuts,
|
||||
addConfigEditorRecent,
|
||||
removeConfigEditorRecent,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned,
|
||||
addConfigEditorShortcut,
|
||||
removeConfigEditorShortcut,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH
|
||||
} from '@/ui/main-axios.ts';
|
||||
|
||||
interface Tab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
isSSH?: boolean;
|
||||
sshSessionId?: string;
|
||||
filePath?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function FileManager({onSelectView, embedded = false, initialHost = null}: { onSelectView?: (view: string) => void, embedded?: boolean, initialHost?: SSHHost | null }): React.ReactElement {
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
const [pinned, setPinned] = useState<any[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
||||
|
||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const sidebarRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
||||
setCurrentHost(initialHost);
|
||||
// Defer to ensure sidebar is mounted
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const path = initialHost.defaultPath || '/';
|
||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||
sidebarRef.current.openFolder(initialHost, path);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [initialHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
} else {
|
||||
setRecent([]);
|
||||
setPinned([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
}, [currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'home' && currentHost) {
|
||||
const interval = setInterval(() => {
|
||||
fetchHomeData();
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
async function fetchHomeData() {
|
||||
if (!currentHost) return;
|
||||
|
||||
try {
|
||||
const homeDataPromise = Promise.all([
|
||||
getConfigEditorRecent(currentHost.id),
|
||||
getConfigEditorPinned(currentHost.id),
|
||||
getConfigEditorShortcuts(currentHost.id),
|
||||
]);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
|
||||
);
|
||||
|
||||
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
|
||||
|
||||
const recentWithPinnedStatus = (recentRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file',
|
||||
isPinned: (pinnedRes || []).some(pinnedFile =>
|
||||
pinnedFile.path === file.path && pinnedFile.name === file.name
|
||||
)
|
||||
}));
|
||||
|
||||
const pinnedWithType = (pinnedRes || []).map(file => ({
|
||||
...file,
|
||||
type: 'file'
|
||||
}));
|
||||
|
||||
setRecent(recentWithPinnedStatus);
|
||||
setPinned(pinnedWithType);
|
||||
setShortcuts((shortcutsRes || []).map(shortcut => ({
|
||||
...shortcut,
|
||||
type: 'directory'
|
||||
})));
|
||||
} catch (err: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const formatErrorMessage = (err: any, defaultMessage: string): string => {
|
||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const axiosErr = err as any;
|
||||
if (axiosErr.response?.status === 403) {
|
||||
return `Permission denied. ${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
} else if (axiosErr.response?.status === 500) {
|
||||
const backendError = axiosErr.response?.data?.error || 'Internal server error occurred';
|
||||
return `Server Error (500): ${backendError}. Check the Docker logs for detailed error information.`;
|
||||
} else if (axiosErr.response?.data?.error) {
|
||||
const backendError = axiosErr.response.data.error;
|
||||
return `${axiosErr.response?.status ? `Error ${axiosErr.response.status}: ` : ''}${backendError}. Check the Docker logs for detailed error information.`;
|
||||
} else {
|
||||
return `Request failed with status code ${axiosErr.response?.status || 'unknown'}. Check the Docker logs for detailed error information.`;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
return `${err.message}. Check the Docker logs for detailed error information.`;
|
||||
} else {
|
||||
return `${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFile = async (file: any) => {
|
||||
const tabId = file.path;
|
||||
|
||||
if (!tabs.find(t => t.id === tabId)) {
|
||||
const currentSshSessionId = currentHost?.id.toString();
|
||||
|
||||
setTabs([...tabs, {
|
||||
id: tabId,
|
||||
title: file.name,
|
||||
fileName: file.name,
|
||||
content: '',
|
||||
filePath: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
loading: true
|
||||
}]);
|
||||
try {
|
||||
const res = await readSSHFile(currentSshSessionId, file.path);
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content: res.content,
|
||||
loading: false,
|
||||
error: undefined
|
||||
} : t));
|
||||
await addConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err: any) {
|
||||
const errorMessage = formatErrorMessage(err, 'Cannot read file');
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
|
||||
}
|
||||
}
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handleRemoveRecent = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinFile = async (file: any) => {
|
||||
try {
|
||||
await addConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (file: any) => {
|
||||
try {
|
||||
await removeConfigEditorPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShortcut = async (shortcut: any) => {
|
||||
if (sidebarRef.current?.isOpeningShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||
try {
|
||||
sidebarRef.current.isOpeningShortcut = true;
|
||||
|
||||
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
|
||||
|
||||
await sidebarRef.current.openFolder(currentHost, normalizedPath);
|
||||
} catch (err) {
|
||||
} finally {
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.isOpeningShortcut = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShortcut = async (folderPath: string) => {
|
||||
try {
|
||||
const name = folderPath.split('/').pop() || folderPath;
|
||||
await addConfigEditorShortcut({
|
||||
name,
|
||||
path: folderPath,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (shortcut: any) => {
|
||||
try {
|
||||
await removeConfigEditorShortcut({
|
||||
name: shortcut.name,
|
||||
path: shortcut.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {
|
||||
}
|
||||
};
|
||||
|
||||
const closeTab = (tabId: string | number) => {
|
||||
const idx = tabs.findIndex(t => t.id === tabId);
|
||||
const newTabs = tabs.filter(t => t.id !== tabId);
|
||||
setTabs(newTabs);
|
||||
if (activeTab === tabId) {
|
||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
||||
else setActiveTab('home');
|
||||
}
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
};
|
||||
|
||||
const setTabContent = (tabId: string | number, content: string) => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {
|
||||
...t,
|
||||
content,
|
||||
dirty: true,
|
||||
error: undefined,
|
||||
success: undefined
|
||||
} : t));
|
||||
};
|
||||
|
||||
const handleSave = async (tab: Tab) => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (!tab.sshSessionId) {
|
||||
throw new Error('No SSH session ID available');
|
||||
}
|
||||
|
||||
if (!tab.filePath) {
|
||||
throw new Error('No file path available');
|
||||
}
|
||||
|
||||
if (!currentHost?.id) {
|
||||
throw new Error('No current host available');
|
||||
}
|
||||
|
||||
try {
|
||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
|
||||
);
|
||||
|
||||
const status = await Promise.race([statusPromise, statusTimeoutPromise]);
|
||||
|
||||
if (!status.connected) {
|
||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: currentHost.password,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword
|
||||
});
|
||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
|
||||
);
|
||||
|
||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
||||
}
|
||||
} catch (statusErr) {
|
||||
}
|
||||
|
||||
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => {
|
||||
reject(new Error('Save operation timed out'));
|
||||
}, 30000)
|
||||
);
|
||||
|
||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
dirty: false,
|
||||
success: 'File saved successfully'
|
||||
} : t));
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
|
||||
}, 3000);
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
try {
|
||||
await addConfigEditorRecent({
|
||||
name: tab.fileName,
|
||||
path: tab.filePath,
|
||||
isSSH: true,
|
||||
sshSessionId: tab.sshSessionId,
|
||||
hostId: currentHost.id
|
||||
});
|
||||
} catch (recentErr) {
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
await fetchHomeData();
|
||||
} catch (refreshErr) {
|
||||
}
|
||||
})()
|
||||
]).then(() => {
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
let errorMessage = formatErrorMessage(err, 'Cannot save file');
|
||||
|
||||
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
|
||||
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
|
||||
}
|
||||
|
||||
setTabs(tabs => {
|
||||
const updatedTabs = tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
error: `Failed to save file: ${errorMessage}`
|
||||
} : t);
|
||||
return updatedTabs;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setTabs(currentTabs => [...currentTabs]);
|
||||
}, 100);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Host is locked; no external host change from UI
|
||||
const handleHostChange = (_host: SSHHost | null) => {};
|
||||
|
||||
if (!currentHost) {
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {})}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
host={initialHost as SSHHost}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
|
||||
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {})}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
host={currentHost as SSHHost}
|
||||
/>
|
||||
</div>
|
||||
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
|
||||
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-11 relative px-4"
|
||||
style={{height: 44}}>
|
||||
{/* Tab list scrollable area */}
|
||||
<div className="flex-1 min-w-0 h-full flex items-center">
|
||||
<div
|
||||
className="h-9 w-full bg-[#09090b] border-2 border-[#303032] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
||||
style={{minWidth: 0}}>
|
||||
<FIleManagerTopNavbar
|
||||
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={() => {
|
||||
setActiveTab('home');
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Save button - always visible */}
|
||||
<Button
|
||||
className={cn(
|
||||
'ml-4 px-4 py-1.5 border rounded-md text-sm font-medium transition-colors',
|
||||
'border-[#2d2d30] text-white bg-transparent hover:bg-[#23232a] active:bg-[#23232a] focus:bg-[#23232a]',
|
||||
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
|
||||
)}
|
||||
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
|
||||
onClick={() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (tab && !isSaving) handleSave(tab);
|
||||
}}
|
||||
type="button"
|
||||
style={{height: 36, alignSelf: 'center'}}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 44,
|
||||
left: 256,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
background: '#101014',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{activeTab === 'home' ? (
|
||||
<FileManagerHomeView
|
||||
recent={recent}
|
||||
pinned={pinned}
|
||||
shortcuts={shortcuts}
|
||||
onOpenFile={handleOpenFile}
|
||||
onRemoveRecent={handleRemoveRecent}
|
||||
onPinFile={handlePinFile}
|
||||
onUnpinFile={handleUnpinFile}
|
||||
onOpenShortcut={handleOpenShortcut}
|
||||
onRemoveShortcut={handleRemoveShortcut}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find(t => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||
{/* Error display */}
|
||||
{tab.error && (
|
||||
<div
|
||||
className="bg-red-900/20 border border-red-500/30 text-red-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-400">⚠️</span>
|
||||
<span>{tab.error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
error: undefined
|
||||
} : t))}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Success display */}
|
||||
{tab.success && (
|
||||
<div
|
||||
className="bg-green-900/20 border border-green-500/30 text-green-300 px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-400">✓</span>
|
||||
<span>{tab.success}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
success: undefined
|
||||
} : t))}
|
||||
className="text-green-400 hover:text-green-300 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileManagerFileEditor
|
||||
content={tab.content}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={content => setTabContent(tab.id, content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
350
src/ui/apps/File Manager/FileManagerFileEditor.tsx
Normal file
350
src/ui/apps/File Manager/FileManagerFileEditor.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
|
||||
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
|
||||
import {oneDark} from '@codemirror/theme-one-dark';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
|
||||
interface ConfigCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerFileEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'text';
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1) {
|
||||
return 'text';
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'ng':
|
||||
return 'angular';
|
||||
case 'apl':
|
||||
return 'apl';
|
||||
case 'asc':
|
||||
return 'asciiArmor';
|
||||
case 'ast':
|
||||
return 'asterisk';
|
||||
case 'bf':
|
||||
return 'brainfuck';
|
||||
case 'c':
|
||||
return 'c';
|
||||
case 'ceylon':
|
||||
return 'ceylon';
|
||||
case 'clj':
|
||||
return 'clojure';
|
||||
case 'cmake':
|
||||
return 'cmake';
|
||||
case 'cob':
|
||||
case 'cbl':
|
||||
return 'cobol';
|
||||
case 'coffee':
|
||||
return 'coffeescript';
|
||||
case 'lisp':
|
||||
return 'commonLisp';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
return 'cpp';
|
||||
case 'cr':
|
||||
return 'crystal';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'cypher':
|
||||
return 'cypher';
|
||||
case 'd':
|
||||
return 'd';
|
||||
case 'dart':
|
||||
return 'dart';
|
||||
case 'diff':
|
||||
case 'patch':
|
||||
return 'diff';
|
||||
case 'dockerfile':
|
||||
return 'dockerfile';
|
||||
case 'dtd':
|
||||
return 'dtd';
|
||||
case 'dylan':
|
||||
return 'dylan';
|
||||
case 'ebnf':
|
||||
return 'ebnf';
|
||||
case 'ecl':
|
||||
return 'ecl';
|
||||
case 'eiffel':
|
||||
return 'eiffel';
|
||||
case 'elm':
|
||||
return 'elm';
|
||||
case 'erl':
|
||||
return 'erlang';
|
||||
case 'factor':
|
||||
return 'factor';
|
||||
case 'fcl':
|
||||
return 'fcl';
|
||||
case 'fs':
|
||||
return 'forth';
|
||||
case 'f90':
|
||||
case 'for':
|
||||
return 'fortran';
|
||||
case 's':
|
||||
return 'gas';
|
||||
case 'feature':
|
||||
return 'gherkin';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'groovy':
|
||||
return 'groovy';
|
||||
case 'hs':
|
||||
return 'haskell';
|
||||
case 'hx':
|
||||
return 'haxe';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'http':
|
||||
return 'http';
|
||||
case 'idl':
|
||||
return 'idl';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return 'javascript';
|
||||
case 'jinja2':
|
||||
case 'j2':
|
||||
return 'jinja2';
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'jsx':
|
||||
return 'jsx';
|
||||
case 'jl':
|
||||
return 'julia';
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return 'kotlin';
|
||||
case 'less':
|
||||
return 'less';
|
||||
case 'lezer':
|
||||
return 'lezer';
|
||||
case 'liquid':
|
||||
return 'liquid';
|
||||
case 'litcoffee':
|
||||
return 'livescript';
|
||||
case 'lua':
|
||||
return 'lua';
|
||||
case 'md':
|
||||
return 'markdown';
|
||||
case 'nb':
|
||||
case 'mat':
|
||||
return 'mathematica';
|
||||
case 'mbox':
|
||||
return 'mbox';
|
||||
case 'mmd':
|
||||
return 'mermaid';
|
||||
case 'mrc':
|
||||
return 'mirc';
|
||||
case 'moo':
|
||||
return 'modelica';
|
||||
case 'mscgen':
|
||||
return 'mscgen';
|
||||
case 'm':
|
||||
return 'mumps';
|
||||
case 'sql':
|
||||
return 'mysql';
|
||||
case 'nc':
|
||||
return 'nesC';
|
||||
case 'nginx':
|
||||
return 'nginx';
|
||||
case 'nix':
|
||||
return 'nix';
|
||||
case 'nsi':
|
||||
return 'nsis';
|
||||
case 'nt':
|
||||
return 'ntriples';
|
||||
case 'mm':
|
||||
return 'objectiveCpp';
|
||||
case 'octave':
|
||||
return 'octave';
|
||||
case 'oz':
|
||||
return 'oz';
|
||||
case 'pas':
|
||||
return 'pascal';
|
||||
case 'pl':
|
||||
case 'pm':
|
||||
return 'perl';
|
||||
case 'pgsql':
|
||||
return 'pgsql';
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'pig':
|
||||
return 'pig';
|
||||
case 'ps1':
|
||||
return 'powershell';
|
||||
case 'properties':
|
||||
return 'properties';
|
||||
case 'proto':
|
||||
return 'protobuf';
|
||||
case 'pp':
|
||||
return 'puppet';
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'q':
|
||||
return 'q';
|
||||
case 'r':
|
||||
return 'r';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'sas':
|
||||
return 'sas';
|
||||
case 'sass':
|
||||
case 'scss':
|
||||
return 'sass';
|
||||
case 'scala':
|
||||
return 'scala';
|
||||
case 'scm':
|
||||
return 'scheme';
|
||||
case 'shader':
|
||||
return 'shader';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
return 'shell';
|
||||
case 'siv':
|
||||
return 'sieve';
|
||||
case 'st':
|
||||
return 'smalltalk';
|
||||
case 'sol':
|
||||
return 'solidity';
|
||||
case 'solr':
|
||||
return 'solr';
|
||||
case 'rq':
|
||||
return 'sparql';
|
||||
case 'xlsx':
|
||||
case 'ods':
|
||||
case 'csv':
|
||||
return 'spreadsheet';
|
||||
case 'nut':
|
||||
return 'squirrel';
|
||||
case 'tex':
|
||||
return 'stex';
|
||||
case 'styl':
|
||||
return 'stylus';
|
||||
case 'svelte':
|
||||
return 'svelte';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'tcl':
|
||||
return 'tcl';
|
||||
case 'textile':
|
||||
return 'textile';
|
||||
case 'tiddlywiki':
|
||||
return 'tiddlyWiki';
|
||||
case 'tiki':
|
||||
return 'tiki';
|
||||
case 'toml':
|
||||
return 'toml';
|
||||
case 'troff':
|
||||
return 'troff';
|
||||
case 'tsx':
|
||||
return 'tsx';
|
||||
case 'ttcn':
|
||||
return 'ttcn';
|
||||
case 'ttl':
|
||||
case 'turtle':
|
||||
return 'turtle';
|
||||
case 'ts':
|
||||
return 'typescript';
|
||||
case 'vb':
|
||||
return 'vb';
|
||||
case 'vbs':
|
||||
return 'vbscript';
|
||||
case 'vm':
|
||||
return 'velocity';
|
||||
case 'v':
|
||||
return 'verilog';
|
||||
case 'vhd':
|
||||
case 'vhdl':
|
||||
return 'vhdl';
|
||||
case 'vue':
|
||||
return 'vue';
|
||||
case 'wat':
|
||||
return 'wast';
|
||||
case 'webidl':
|
||||
return 'webIDL';
|
||||
case 'xq':
|
||||
case 'xquery':
|
||||
return 'xQuery';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'yacas':
|
||||
return 'yacas';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'z80':
|
||||
return 'z80';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflowX = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflowX = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
className="config-codemirror-scroll-wrapper"
|
||||
>
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: '#09090b !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#18181b !important',
|
||||
},
|
||||
})
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{lineNumbers: true}}
|
||||
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/ui/apps/File Manager/FileManagerHomeView.tsx
Normal file
206
src/ui/apps/File Manager/FileManagerHomeView.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isPinned?: boolean;
|
||||
type: 'file' | 'directory';
|
||||
sshSessionId?: string;
|
||||
}
|
||||
|
||||
interface ShortcutItem {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ConfigHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: ConfigHomeViewProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
|
||||
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
|
||||
<div key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div key={shortcut.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
||||
Shortcuts</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||
</div>
|
||||
) : recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
|
||||
file.isPinned
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
undefined,
|
||||
() => onUnpinFile(file),
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
|
||||
<Input
|
||||
placeholder="Enter folder path"
|
||||
value={newShortcut}
|
||||
onChange={e => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 bg-[#23232a] border-2 !border-[#303032] hover:bg-[#2d2d30] rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
385
src/ui/apps/File Manager/FileManagerLeftSidebar.tsx
Normal file
385
src/ui/apps/File Manager/FileManagerLeftSidebar.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
|
||||
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {
|
||||
listSSHFiles,
|
||||
connectSSH,
|
||||
getSSHStatus,
|
||||
getConfigEditorPinned,
|
||||
addConfigEditorPinned,
|
||||
removeConfigEditorPinned
|
||||
} from '@/ui/main-axios.ts';
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableConfigEditor: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const FileManagerLeftSidebar = forwardRef(function ConfigEditorSidebar(
|
||||
{onSelectView, onOpenFile, tabs, host}: {
|
||||
onSelectView?: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: any[];
|
||||
host: SSHHost;
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [fileSearch, setFileSearch] = useState('');
|
||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [fileSearch]);
|
||||
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [filesError, setFilesError] = useState<string | null>(null);
|
||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||
const [connectionCache, setConnectionCache] = useState<Record<string, {
|
||||
sessionId: string;
|
||||
timestamp: number
|
||||
}>>({});
|
||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// when host changes, set path and connect
|
||||
const nextPath = host?.defaultPath || '/';
|
||||
setCurrentPath(nextPath);
|
||||
(async () => {
|
||||
await connectToSSH(host);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [host?.id]);
|
||||
|
||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||
const sessionId = server.id.toString();
|
||||
|
||||
const cached = connectionCache[sessionId];
|
||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
||||
setSshSessionId(cached.sessionId);
|
||||
return cached.sessionId;
|
||||
}
|
||||
|
||||
if (connectingSSH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setConnectingSSH(true);
|
||||
|
||||
try {
|
||||
if (!server.password && !server.key) {
|
||||
setFilesError('No authentication credentials available for this SSH host');
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionConfig = {
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
sshKey: server.key,
|
||||
keyPassword: server.keyPassword,
|
||||
};
|
||||
|
||||
await connectSSH(sessionId, connectionConfig);
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
setConnectionCache(prev => ({
|
||||
...prev,
|
||||
[sessionId]: {sessionId, timestamp: Date.now()}
|
||||
}));
|
||||
|
||||
return sessionId;
|
||||
} catch (err: any) {
|
||||
setFilesError(err?.response?.data?.error || 'Failed to connect to SSH');
|
||||
setSshSessionId(null);
|
||||
return null;
|
||||
} finally {
|
||||
setConnectingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
if (fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(true);
|
||||
setFiles([]);
|
||||
setFilesLoading(true);
|
||||
setFilesError(null);
|
||||
|
||||
try {
|
||||
let pinnedFiles: any[] = [];
|
||||
try {
|
||||
if (host) {
|
||||
pinnedFiles = await getConfigEditorPinned(host.id);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
|
||||
if (host && sshSessionId) {
|
||||
let res: any[] = [];
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
if (!status.connected) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw new Error('Failed to reconnect SSH session');
|
||||
}
|
||||
} else {
|
||||
res = await listSSHFiles(sshSessionId, currentPath);
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
|
||||
const processedFiles = (res || []).map((f: any) => {
|
||||
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
|
||||
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
|
||||
return {
|
||||
...f,
|
||||
path: filePath,
|
||||
isPinned,
|
||||
isSSH: true,
|
||||
sshSessionId: sshSessionId
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(processedFiles);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFiles([]);
|
||||
setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files');
|
||||
} finally {
|
||||
setFilesLoading(false);
|
||||
setFetchingFiles(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchFiles();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [currentPath, host, sshSessionId]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openFolder: async (_server: SSHHost, path: string) => {
|
||||
if (connectingSSH || fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath === path) {
|
||||
setTimeout(() => fetchFiles(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFilesError(null);
|
||||
setFiles([]);
|
||||
|
||||
setCurrentPath(path);
|
||||
if (!sshSessionId) {
|
||||
const sessionId = await connectToSSH(host);
|
||||
if (sessionId) setSshSessionId(sessionId);
|
||||
}
|
||||
},
|
||||
fetchFiles: () => {
|
||||
if (host && sshSessionId) {
|
||||
fetchFiles();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (pathInputRef.current) {
|
||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
const filteredFiles = files.filter(file => {
|
||||
const q = debouncedFileSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return file.name.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}>
|
||||
<div className="flex flex-col flex-grow min-h-0">
|
||||
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
|
||||
{host && (
|
||||
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => {
|
||||
let path = currentPath;
|
||||
if (path && path !== '/' && path !== '') {
|
||||
if (path.endsWith('/')) path = path.slice(0, -1);
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
setCurrentPath(path.slice(0, lastSlash));
|
||||
} else {
|
||||
setCurrentPath('/');
|
||||
}
|
||||
} else {
|
||||
setCurrentPath('/');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Input ref={pathInputRef} value={currentPath}
|
||||
onChange={e => setCurrentPath(e.target.value)}
|
||||
className="flex-1 bg-[#18181b] border-2 border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-2 border-b-1 border-[#303032] bg-[#18181b]">
|
||||
<Input
|
||||
placeholder="Search files and folders..."
|
||||
className="w-full h-7 text-sm bg-[#23232a] border-2 border-[#434345] text-white placeholder:text-muted-foreground rounded-md"
|
||||
autoComplete="off"
|
||||
value={fileSearch}
|
||||
onChange={e => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 w-full bg-[#09090b] border-t-1 border-[#303032]">
|
||||
<ScrollArea className="h-full w-full bg-[#09090b]">
|
||||
<div className="p-2 pb-0">
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : filesError ? (
|
||||
<div className="text-xs text-red-500">{filesError}</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full",
|
||||
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
)}
|
||||
style={{maxWidth: 220, marginBottom: 8}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => !isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: item.sshSessionId
|
||||
}))}
|
||||
>
|
||||
{item.type === 'directory' ?
|
||||
<Folder className="w-4 h-4 text-blue-400"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === 'file' && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
disabled={isOpen}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (item.isPinned) {
|
||||
await removeConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: host?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: host?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? { ...f, isPinned: false } : f
|
||||
));
|
||||
} else {
|
||||
await addConfigEditorPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: host?.id,
|
||||
isSSH: true,
|
||||
sshSessionId: host?.id.toString()
|
||||
});
|
||||
setFiles(files.map(f =>
|
||||
f.path === item.path ? { ...f, isPinned: true } : f
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pin/unpin file:', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export {FileManagerLeftSidebar};
|
||||
149
src/ui/apps/File Manager/FileManagerLeftSidebarFileViewer.tsx
Normal file
149
src/ui/apps/File Manager/FileManagerLeftSidebarFileViewer.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: 'file' | 'directory' | 'link';
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigFileSidebarViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function FileManagerLeftSidebarFileViewer({
|
||||
sshConnections,
|
||||
onAddSSH,
|
||||
onConnectSSH,
|
||||
onEditSSH,
|
||||
onDeleteSSH,
|
||||
onPinSSH,
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
onSwitchToLocal,
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: ConfigFileSidebarViewerProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* SSH Connections */}
|
||||
<div className="p-2 bg-[#18181b] border-b-2 border-[#303032]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
|
||||
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
|
||||
<Plus className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant={!isSSHMode ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={onSwitchToLocal}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2"/> Local Files
|
||||
</Button>
|
||||
{sshConnections.map((conn) => (
|
||||
<div key={conn.id} className="flex items-center gap-1 group">
|
||||
<Button
|
||||
variant={isSSHMode && currentSSH?.id === conn.id ? 'secondary' : 'ghost'}
|
||||
className="flex-1 justify-start text-left px-2 py-1.5 rounded"
|
||||
onClick={() => onSwitchToSSH(conn)}
|
||||
>
|
||||
<Link2 className="w-4 h-4 mr-2"/>
|
||||
{conn.name || conn.ip}
|
||||
{conn.isPinned && <Pin className="w-3 h-3 ml-1 text-yellow-400"/>}
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onPinSSH(conn)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${conn.isPinned ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onEditSSH(conn)}>
|
||||
<Edit className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => onDeleteSSH(conn)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* File/Folder Viewer */}
|
||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card key={item.path}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border-2 border-[#303032] rounded">
|
||||
<div className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
|
||||
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
|
||||
<File className="w-4 h-4 text-muted-foreground"/>}
|
||||
<span className="text-sm text-white truncate">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}>
|
||||
<Trash2 className="w-4 h-4 text-red-500"/>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 &&
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/ui/apps/File Manager/FileManagerTabList.tsx
Normal file
57
src/ui/apps/File Manager/FileManagerTabList.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {X, Home} from 'lucide-react';
|
||||
|
||||
interface ConfigTab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ConfigTabListProps {
|
||||
tabs: ConfigTab[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: ConfigTabListProps) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-2 !border-[#303032] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
<Home className="w-4 h-4"/>
|
||||
</Button>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={index < tabs.length - 1 ? "mr-[0.5rem]" : ""}
|
||||
>
|
||||
<div className="inline-flex rounded-md shadow-sm" role="group">
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-2 !border-[#303032] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-7 rounded-l-none p-0 !w-9"
|
||||
>
|
||||
<X className="!w-5 !h-5" strokeWidth={2.5}/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user