diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..14c1d8b7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [LukeGus] \ No newline at end of file diff --git a/src/apps/Config Editor/ConfigCodeEditor.tsx b/src/apps/Config Editor/ConfigCodeEditor.tsx index 75e43a0e..de688803 100644 --- a/src/apps/Config Editor/ConfigCodeEditor.tsx +++ b/src/apps/Config Editor/ConfigCodeEditor.tsx @@ -1,95 +1,168 @@ import React, { useState, useEffect } from "react"; import CodeMirror from "@uiw/react-codemirror"; -import type { LanguageName } from '@uiw/codemirror-extensions-langs'; 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'; -import { createTheme } from '@uiw/codemirror-themes'; -import { tags as t } from '@lezer/highlight'; interface ConfigCodeEditorProps { content: string; - fileName: string; + fileNameOld: string; onContentChange: (value: string) => void; } -export function ConfigCodeEditor({content, fileName, onContentChange,}: ConfigCodeEditorProps) { - const langName = getLanguageName(fileName); - const langExt = langName ? loadLanguage(langName) : null; - const extensions = [hyperLink]; - if (langExt) extensions.unshift(langExt); +export function ConfigCodeEditor({content, fileNameOld, onContentChange}: ConfigCodeEditorProps) { + const fileName = "test.js" - // Custom theme based on built-in 'dark', with overrides for background, gutter, and font size - const customDarkTheme = [ - createTheme({ - theme: 'dark', - settings: { - background: '#09090b', - gutterBackground: '#18181b', - gutterForeground: 'oklch(0.985 0 0)', - foreground: '#e0e0e0', - caret: '#ffcc00', - selection: '#22223b99', - selectionMatch: '#22223b66', - lineHighlight: '#18181b', - gutterBorder: '1px solid #22223b', - }, - styles: [ - { tag: t.keyword, color: '#ff5370' }, // red - { tag: t.string, color: '#c3e88d' }, // green - { tag: t.number, color: '#82aaff' }, // blue - { tag: t.comment, color: '#5c6370' }, // gray - { tag: t.variableName, color: '#f78c6c' }, // orange - { tag: t.function(t.variableName), color: '#82aaff' }, // blue - { tag: t.typeName, color: '#ffcb6b' }, // yellow - { tag: t.className, color: '#ffcb6b' }, // yellow - { tag: t.definition(t.typeName), color: '#ffcb6b' }, // yellow - { tag: t.operator, color: '#89ddff' }, // cyan - { tag: t.bool, color: '#f78c6c' }, // orange - { tag: t.null, color: '#f78c6c' }, // orange - { tag: t.tagName, color: '#ff5370' }, // red - { tag: t.attributeName, color: '#c792ea' }, // purple - { tag: t.angleBracket, color: '#89ddff' }, // cyan - ], - }), - EditorView.theme({ - '&': { - fontSize: '13px', - }, - }), - ]; - - function getLanguageName(filename: string): LanguageName | undefined { + function getLanguageName(filename: string): string { const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase(); + switch (ext) { - case 'js': - case 'mjs': - case 'cjs': return 'javascript'; - case 'ts': return 'typescript'; - case 'tsx': return 'tsx'; - case 'json': return 'json'; - case 'css': return 'css'; - case 'html': - case 'htm': return 'html'; - case 'md': return 'markdown'; - case 'py': return 'python'; - case 'sh': return 'shell'; - case 'yaml': - case 'yml': return 'yaml'; - case 'go': return 'go'; - case 'java': return 'java'; + 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 'rs': return 'rust'; - case 'php': return 'php'; - case 'rb': return 'ruby'; - case 'swift': return 'swift'; + 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 'sql': return 'sql'; - default: return undefined; + case 'yacas': return 'yacas'; + case 'yaml': + case 'yml': return 'yaml'; + case 'z80': return 'z80'; + default: return 'js'; } } @@ -115,9 +188,21 @@ export function ConfigCodeEditor({content, fileName, onContentChange,}: ConfigCo > onContentChange(value)} - theme={customDarkTheme} + theme={undefined} height="100%" basicSetup={{ lineNumbers: true }} style={{ minHeight: '100%', minWidth: '100%', flex: 1 }} diff --git a/src/apps/Config Editor/ConfigEditor.tsx b/src/apps/Config Editor/ConfigEditor.tsx index dafe1efa..c51f8bb7 100644 --- a/src/apps/Config Editor/ConfigEditor.tsx +++ b/src/apps/Config Editor/ConfigEditor.tsx @@ -1,33 +1,232 @@ -import React, { useState } from "react"; -import {ConfigEditorSidebar} from "@/apps/Config Editor/ConfigEditorSidebar.tsx"; -import {ConfigCodeEditor} from "@/apps/Config Editor/ConfigCodeEditor.tsx"; -import {ConfigTopbar} from "@/apps/Config Editor/ConfigTopbar.tsx"; +import React, { useState, useEffect, useRef } from "react"; +import { ConfigEditorSidebar } from "@/apps/Config Editor/ConfigEditorSidebar"; +import { ConfigTabList } from "@/apps/Config Editor/ConfigTabList"; +import { ConfigHomeView } from "@/apps/Config Editor/ConfigHomeView"; +import { ConfigCodeEditor } from "@/apps/Config Editor/ConfigCodeEditor"; +import axios from 'axios'; +import { Button } from '@/components/ui/button'; +import { ConfigTopbar } from "@/apps/Config Editor/ConfigTopbar"; +import { cn } from '@/lib/utils'; -interface ConfigEditorProps { - onSelectView: (view: string) => void; +function getJWT() { + return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; } -export function ConfigEditor({onSelectView}: ConfigEditorProps): React.ReactElement { - const [content, setContent] = useState(""); - const [fileName, setFileName] = useState("config.yaml"); +interface Tab { + id: string | number; + title: string; + fileName: string; + content: string; + isSSH?: boolean; + sshSessionId?: string; + filePath?: string; + loading?: boolean; + error?: string; + dirty?: boolean; +} + +export function ConfigEditor({ onSelectView }: { onSelectView: (view: string) => void }): React.ReactElement { + const [tabs, setTabs] = useState([]); + const [activeTab, setActiveTab] = useState('home'); + const [recent, setRecent] = useState([]); + const [pinned, setPinned] = useState([]); + const [shortcuts, setShortcuts] = useState([]); + const [loadingHome, setLoadingHome] = useState(false); + const [errorHome, setErrorHome] = useState(undefined); + + const API_BASE_DB = 'http://localhost:8081'; // For database-backed endpoints + const API_BASE = 'http://localhost:8084'; // For stateless file/ssh operations + + const sidebarRef = useRef(null); + + // Fetch home data + useEffect(() => { + fetchHomeData(); + }, []); + async function fetchHomeData() { + setLoadingHome(true); + setErrorHome(undefined); + try { + const jwt = getJWT(); + const [recentRes, pinnedRes, shortcutsRes] = await Promise.all([ + axios.get(`${API_BASE_DB}/config_editor/recent`, { headers: { Authorization: `Bearer ${jwt}` } }), + axios.get(`${API_BASE_DB}/config_editor/pinned`, { headers: { Authorization: `Bearer ${jwt}` } }), + axios.get(`${API_BASE_DB}/config_editor/shortcuts`, { headers: { Authorization: `Bearer ${jwt}` } }), + ]); + setRecent(recentRes.data || []); + setPinned(pinnedRes.data || []); + setShortcuts(shortcutsRes.data || []); + } catch (err: any) { + setErrorHome('Failed to load home data'); + } finally { + setLoadingHome(false); + } + } + + // Home view actions + const handleOpenFile = async (file: any) => { + const tabId = file.path; + if (!tabs.find(t => t.id === tabId)) { + setTabs([...tabs, { id: tabId, title: file.name, fileName: file.name, content: '', filePath: file.path, isSSH: file.isSSH, sshSessionId: file.sshSessionId, loading: true }]); + try { + let content = ''; + const jwt = getJWT(); + if (file.isSSH) { + const res = await axios.get(`${API_BASE}/ssh/readFile`, { params: { sessionId: file.sshSessionId, path: file.path }, headers: { Authorization: `Bearer ${jwt}` } }); + content = res.data.content; + } else { + const folder = file.path.substring(0, file.path.lastIndexOf('/')); + const res = await axios.get(`${API_BASE}/file`, { params: { folder, name: file.name }, headers: { Authorization: `Bearer ${jwt}` } }); + content = res.data; + } + setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, loading: false, error: undefined } : t)); + // Mark as recent + await axios.post(`${API_BASE_DB}/config_editor/recent`, { name: file.name, path: file.path }, { headers: { Authorization: `Bearer ${jwt}` } }); + fetchHomeData(); + } catch (err: any) { + setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, loading: false, error: err?.message || 'Failed to load file' } : t)); + } + } + setActiveTab(tabId); + }; + const handleRemoveRecent = async (file: any) => { + // Implement backend delete if needed + setRecent(recent.filter(f => f.path !== file.path)); + }; + const handlePinFile = async (file: any) => { + const jwt = getJWT(); + await axios.post(`${API_BASE_DB}/config_editor/pinned`, { name: file.name, path: file.path }, { headers: { Authorization: `Bearer ${jwt}` } }); + fetchHomeData(); + }; + const handleOpenShortcut = async (shortcut: any) => { + // Find the server for this shortcut (local or SSH) + let server: any = { isLocal: true, name: 'Local Files', id: 'local', defaultPath: '/' }; + if (shortcut.server) { + server = shortcut.server; + } + // Use the sidebar's openFolder method + if ((window as any).configSidebarRef && (window as any).configSidebarRef.openFolder) { + (window as any).configSidebarRef.openFolder(server, shortcut.path); + } + }; + // Add add/remove shortcut logic + const handleAddShortcut = async (folderPath: string) => { + try { + const jwt = getJWT(); + await axios.post(`${API_BASE_DB}/config_editor/shortcuts`, { name: folderPath.split('/').pop(), path: folderPath }); + fetchHomeData(); + } catch {} + }; + const handleRemoveShortcut = async (shortcut: any) => { + try { + const jwt = getJWT(); + await axios.post(`${API_BASE_DB}/config_editor/shortcuts/delete`, { path: shortcut.path }); + fetchHomeData(); + } catch {} + }; + + // Tab actions + 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'); + } + }; + const setTabContent = (tabId: string | number, content: string) => { + setTabs(tabs => tabs.map(t => t.id === tabId ? { ...t, content, dirty: true } : t)); + }; + const handleSave = async (tab: Tab) => { + try { + const jwt = getJWT(); + if (tab.isSSH) { + await axios.post(`${API_BASE}/ssh/writeFile`, { sessionId: tab.sshSessionId, path: tab.filePath, content: tab.content }, { headers: { Authorization: `Bearer ${jwt}` } }); + } else { + await axios.post(`${API_BASE}/file?folder=${encodeURIComponent(tab.filePath?.substring(0, tab.filePath?.lastIndexOf('/')) || '')}&name=${encodeURIComponent(tab.fileName)}`, { content: tab.content }, { headers: { Authorization: `Bearer ${jwt}` } }); + } + setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, dirty: false } : t)); + // Mark as recent + await axios.post(`${API_BASE_DB}/config_editor/recent`, { name: tab.fileName, path: tab.filePath }, { headers: { Authorization: `Bearer ${jwt}` } }); + fetchHomeData(); + } catch (err) { + setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, error: 'Failed to save file' } : t)); + } + }; + return (
- {/* Sidebar - fixed width, full height */}
- +
- {/* Topbar - fixed height, full width minus sidebar */} -
- +
+
+ {/* Tab list scrollable area, full width except for Save button */} +
+
+ ({ id: t.id, title: t.title }))} + activeTab={activeTab} + setActiveTab={setActiveTab} + closeTab={closeTab} + onHomeClick={() => setActiveTab('home')} + /> +
+
+ {/* Save button only for file tabs, stationary at right */} + {activeTab !== 'home' && (() => { + const tab = tabs.find(t => t.id === activeTab); + if (!tab) return null; + return ( + + ); + })()} +
- {/* Editor area - fills remaining space, with padding for sidebar and topbar */} -
- +
+ {activeTab === 'home' ? ( + + ) : ( + (() => { + const tab = tabs.find(t => t.id === activeTab); + if (!tab) return null; + return ( +
+
+ setTabContent(tab.id, content)} + /> +
+
+ ); + })() + )}
- ) + ); } \ No newline at end of file diff --git a/src/apps/Config Editor/ConfigEditorSidebar.tsx b/src/apps/Config Editor/ConfigEditorSidebar.tsx index 71303884..bcadc15b 100644 --- a/src/apps/Config Editor/ConfigEditorSidebar.tsx +++ b/src/apps/Config Editor/ConfigEditorSidebar.tsx @@ -1,60 +1,1119 @@ -import React from 'react'; - -import { - CornerDownLeft -} from "lucide-react" - -import { - Button -} from "@/components/ui/button.tsx" - +import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuItem, SidebarProvider, -} from "@/components/ui/sidebar.tsx" + SidebarGroupLabel, SidebarMenu, SidebarMenuItem, + SidebarProvider +} from '@/components/ui/sidebar'; +import {Separator} from '@/components/ui/separator'; +import Icon from '../../../public/icon.svg'; +import {Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetFooter, SheetClose} from '@/components/ui/sheet'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, ArrowUp, CornerDownLeft} from 'lucide-react'; +import axios from 'axios'; +import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'; +import {Switch} from '@/components/ui/switch'; +import {SheetDescription} from '@/components/ui/sheet'; +import {Form, FormField, FormItem, FormLabel, FormControl, FormMessage} from '@/components/ui/form'; +import {zodResolver} from '@hookform/resolvers/zod'; +import {useForm, FormProvider} from 'react-hook-form'; +import {z} from 'zod'; +import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'; +import {MoreVertical} from 'lucide-react'; +import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion'; +import {ScrollArea} from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; -import { - Separator, -} from "@/components/ui/separator.tsx" -import Icon from "../../../public/icon.svg"; - -interface SidebarProps { - onSelectView: (view: string) => void; +function getJWT() { + return document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1]; } -export function ConfigEditorSidebar({ onSelectView }: SidebarProps): React.ReactElement { +const initialSSHForm = {name: '', ip: '', port: 22, username: '', password: '', sshKey: '', isPinned: false}; + +const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( + { onSelectView, onOpenFile, tabs }: { onSelectView: (view: string) => void; onOpenFile: (file: any) => void; tabs: any[] }, + ref +) { + const [addSheetOpen, setAddSheetOpen] = useState(false); + const [addSubmitting, setAddSubmitting] = useState(false); + const [addSubmitError, setAddSubmitError] = useState(null); + const addSSHForm = useForm({ + defaultValues: { + name: '', + ip: '', + port: 22, + username: '', + password: '', + sshKey: '', + sshKeyFile: null, + keyPassword: '', + keyType: 'auto', + isPinned: false, + defaultPath: '/', + folder: '', + authMethod: 'password', + } + }); + React.useEffect(() => { + if (!addSheetOpen) { + setAddSubmitError(null); + addSSHForm.reset(); + } + }, [addSheetOpen]); + const handleAddSSH = () => { + setAddSheetOpen(true); + }; + const onAddSSHSubmit = async (values: any) => { + setAddSubmitError(null); + setAddSubmitting(true); + try { + const jwt = getJWT(); + let sshKeyContent = values.sshKey; + if (values.sshKeyFile instanceof File) { + sshKeyContent = await values.sshKeyFile.text(); + } + const payload = { + name: values.name, + ip: values.ip, + port: values.port, + username: values.username, + password: values.password, + sshKey: sshKeyContent, + keyPassword: values.keyPassword, + keyType: values.keyType, + isPinned: values.isPinned, + defaultPath: values.defaultPath, + folder: values.folder, + authMethod: values.authMethod, + }; + await axios.post(`${API_BASE_DB}/config_editor/ssh/host`, payload, {headers: {Authorization: `Bearer ${jwt}`}}); + await fetchSSH(); + setAddSheetOpen(false); + addSSHForm.reset(); + } catch (err: any) { + setAddSubmitError(err?.response?.data?.error || 'Failed to add SSH connection'); + } finally { + setAddSubmitting(false); + } + }; + const [sshConnections, setSSHConnections] = useState([]); + const [loadingSSH, setLoadingSSH] = useState(false); + const [errorSSH, setErrorSSH] = useState(undefined); + const [view, setView] = useState<'servers' | 'files'>('servers'); + const [activeServer, setActiveServer] = useState(null); + const [currentPath, setCurrentPath] = useState('/'); + const [files, setFiles] = useState([]); + const [sshForm, setSSHForm] = useState(initialSSHForm); + const [editingSSH, setEditingSSH] = useState(null); + const [sshFormError, setSSHFormError] = useState(null); + const [sshFormLoading, setSSHFormLoading] = useState(false); + const pathInputRef = useRef(null); + const [showEditLocal, setShowEditLocal] = useState(false); + const [localDefaultPath, setLocalDefaultPath] = useState('/'); + const [sshPopoverOpen, setSshPopoverOpen] = useState>({}); + const [folders, setFolders] = useState([]); + const [foldersLoading, setFoldersLoading] = useState(false); + const [foldersError, setFoldersError] = useState(null); + const folderInputRef = useRef(null); + const folderDropdownRef = useRef(null); + const [folderInput, setFolderInput] = useState(''); + // Add search bar state + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + useEffect(() => { + const handler = setTimeout(() => setDebouncedSearch(search), 200); + return () => clearTimeout(handler); + }, [search]); + + const API_BASE_DB = 'http://localhost:8081'; // For database-backed endpoints + const API_BASE = 'http://localhost:8084'; // For stateless file/ssh operations + + useEffect(() => { + fetchSSH(); + }, []); + + async function fetchSSH() { + setLoadingSSH(true); + setErrorSSH(undefined); + try { + const jwt = getJWT(); + const res = await axios.get(`${API_BASE_DB}/config_editor/ssh/host`, {headers: {Authorization: `Bearer ${jwt}`}}); + setSSHConnections(res.data || []); + } catch (err: any) { + setErrorSSH('Failed to load SSH connections'); + } finally { + setLoadingSSH(false); + } + } + + // Add state for SSH sessionId and loading/error + const [sshSessionId, setSshSessionId] = useState(null); + const [filesLoading, setFilesLoading] = useState(false); + const [filesError, setFilesError] = useState(null); + + // Helper to connect to SSH and set sessionId + async function connectSSH(server: any): Promise { + const jwt = getJWT(); + const sessionId = server.id || `${server.ip}_${server.port}_${server.username}`; + try { + await axios.post(`${API_BASE}/ssh/connect`, { + sessionId, + ip: server.ip, + port: server.port, + username: server.username, + password: server.password, + sshKey: server.sshKey, + keyPassword: server.keyPassword, + }, { headers: { Authorization: `Bearer ${jwt}` } }); + setSshSessionId(sessionId); + return sessionId; + } catch (err: any) { + setFilesError(err?.response?.data?.error || 'Failed to connect to SSH'); + setSshSessionId(null); + return null; + } + } + + // Modified fetchFiles to handle SSH connect if needed + async function fetchFiles() { + setFiles([]); + setFilesLoading(true); + setFilesError(null); + try { + const jwt = getJWT(); + if (activeServer?.isLocal) { + const res = await axios.get(`${API_BASE}/files`, { + params: { folder: currentPath }, + headers: { Authorization: `Bearer ${jwt}` }, + }); + setFiles((res.data || []).map((f: any) => ({ + ...f, + path: currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name, + isStarred: false, + isSSH: false + }))); + } else if (activeServer) { + // Ensure SSH session is established + let sessionId = sshSessionId; + if (!sessionId || sessionId !== activeServer.id) { + sessionId = await connectSSH(activeServer); + if (!sessionId) { + setFiles([]); + setFilesLoading(false); + return; + } + } + const res = await axios.get(`${API_BASE}/ssh/listFiles`, { + params: { sessionId, path: currentPath }, + headers: { Authorization: `Bearer ${jwt}` }, + }); + setFiles((res.data || []).map((f: any) => ({ + ...f, + path: currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name, + isStarred: false, + isSSH: true, + sshSessionId: sessionId + }))); + } + } catch (err: any) { + setFiles([]); + setFilesError(err?.response?.data?.error || 'Failed to list files'); + } finally { + setFilesLoading(false); + } + } + + // When activeServer or currentPath changes, fetch files + useEffect(() => { + if (view === 'files' && activeServer) fetchFiles(); + // eslint-disable-next-line + }, [currentPath, view, activeServer]); + + // When switching servers, reset sessionId and errors + function handleSelectServer(server: any) { + setActiveServer(server); + setCurrentPath(server.defaultPath || '/'); + setView('files'); + setSshSessionId(server.isLocal ? null : server.id); + setFilesError(null); + } + + useImperativeHandle(ref, () => ({ + openFolder: (server: any, path: string) => { + setActiveServer(server); + setCurrentPath(path); + setView('files'); + setSshSessionId(server.isLocal ? null : server.id); + setFilesError(null); + } + })); + + // SSH Handlers + const handleEditSSH = (conn: any) => { + setEditingSSH(conn); + setSSHForm({...conn}); + // setShowAddSSH(true); // No longer used + }; + const handleDeleteSSH = async (conn: any) => { + try { + const jwt = getJWT(); + await axios.delete(`${API_BASE_DB}/config_editor/ssh/host/${conn.id}`, {headers: {Authorization: `Bearer ${jwt}`}}); + setSSHConnections(sshConnections.filter(s => s.id !== conn.id)); + } catch { + } + }; + const handlePinSSH = async (conn: any) => { + try { + const jwt = getJWT(); + await axios.put(`${API_BASE_DB}/config_editor/ssh/host/${conn.id}`, { + ...conn, + isPinned: !conn.isPinned + }, {headers: {Authorization: `Bearer ${jwt}`}}); + setSSHConnections(sshConnections.map(s => s.id === conn.id ? {...s, isPinned: !s.isPinned} : s)); + } catch { + } + }; + + // Path input focus scroll + useEffect(() => { + if (pathInputRef.current) { + pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth; + } + }, [currentPath]); + + // Fetch folders for popover + useEffect(() => { + async function fetchFolders() { + setFoldersLoading(true); + setFoldersError(null); + try { + const jwt = getJWT(); + const res = await axios.get(`${API_BASE_DB}/config_editor/ssh/folders`, {headers: {Authorization: `Bearer ${jwt}`}}); + setFolders(res.data || []); + } catch (err: any) { + setFoldersError('Failed to load folders'); + } finally { + setFoldersLoading(false); + } + } + + fetchFolders(); + }, [addSheetOpen]); + + const form = useForm({ + defaultValues: { + name: sshForm.name || '', + ip: sshForm.ip || '', + port: sshForm.port || 22, + username: sshForm.username || '', + password: sshForm.password || '', + sshKey: sshForm.sshKey || '', + sshKeyFile: null, + keyPassword: sshForm.keyPassword || '', + keyType: sshForm.keyType || 'auto', + isPinned: sshForm.isPinned || false, + defaultPath: sshForm.defaultPath || '/', + folder: sshForm.folder || '', + authMethod: sshForm.authMethod || 'password', + } + }); + + // 1. SSH edit sheet autofill (debounced to after open, longer delay) + // Remove the useEffect that resets the form on open + // Add a useEffect that resets the form after the sheet is closed + const prevShowAddSSH = React.useRef(addSheetOpen); + useEffect(() => { + if (prevShowAddSSH.current && !addSheetOpen) { + setTimeout(() => { + if (editingSSH) { + form.reset({ + name: editingSSH.name || '', + ip: editingSSH.ip || '', + port: editingSSH.port || 22, + username: editingSSH.username || '', + password: editingSSH.password || '', + sshKey: editingSSH.sshKey || '', + sshKeyFile: null, + keyPassword: editingSSH.keyPassword || '', + keyType: editingSSH.keyType || 'auto', + isPinned: editingSSH.isPinned || false, + defaultPath: editingSSH.defaultPath || '/', + folder: editingSSH.folder || '', + }); + } else { + form.reset({ + name: '', + ip: '', + port: 22, + username: '', + password: '', + sshKey: '', + sshKeyFile: null, + keyPassword: '', + keyType: 'auto', + isPinned: false, + defaultPath: '/', + folder: '', + }); + } + }, 100); + } + prevShowAddSSH.current = addSheetOpen; + }, [addSheetOpen, editingSSH]); + + // 2. Local Files default path persistence + useEffect(() => { + async function fetchLocalDefaultPath() { + try { + const jwt = getJWT(); + const res = await axios.get(`${API_BASE_DB}/config_editor/local_default_path`, {headers: {Authorization: `Bearer ${jwt}`}}); + setLocalDefaultPath(res.data?.defaultPath || '/'); + } catch { + setLocalDefaultPath('/'); + } + } + + fetchLocalDefaultPath(); + }, []); + + async function handleSaveLocalDefaultPath(e: React.FormEvent) { + e.preventDefault(); + try { + const jwt = getJWT(); + await axios.post(`${API_BASE_DB}/config_editor/local_default_path`, {defaultPath: localDefaultPath}, {headers: {Authorization: `Bearer ${jwt}`}}); + setShowEditLocal(false); + } catch { + setShowEditLocal(false); + } + } + + const onSubmit = async (values: any) => { + setSSHFormError(null); + setSSHFormLoading(true); + try { + const jwt = getJWT(); + let sshKeyContent = values.sshKey; + if (values.sshKeyFile instanceof File) { + sshKeyContent = await values.sshKeyFile.text(); + } + const payload = { + name: values.name, + ip: values.ip, + port: values.port, + username: values.username, + password: values.password, + sshKey: sshKeyContent, + keyPassword: values.keyPassword, + keyType: values.keyType, + isPinned: values.isPinned, + defaultPath: values.defaultPath, + folder: values.folder, + authMethod: values.authMethod, + }; + if (editingSSH) { + await axios.put(`${API_BASE_DB}/config_editor/ssh/host/${editingSSH.id}`, payload, {headers: {Authorization: `Bearer ${jwt}`}}); + } else { + await axios.post(`${API_BASE_DB}/config_editor/ssh/host`, payload, {headers: {Authorization: `Bearer ${jwt}`}}); + } + await fetchSSH(); + // setShowAddSSH(false); // No longer used + } catch (err: any) { + setSSHFormError(err?.response?.data?.error || 'Failed to save SSH connection'); + } finally { + setSSHFormLoading(false); + } + }; + + // Group SSH connections by folder + const sshByFolder: Record = {}; + sshConnections.forEach(conn => { + const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder'; + if (!sshByFolder[folder]) sshByFolder[folder] = []; + sshByFolder[folder].push(conn); + }); + // Move 'No Folder' to the top + const sortedFolders = Object.keys(sshByFolder); + if (sortedFolders.includes('No Folder')) { + sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1); + sortedFolders.unshift('No Folder'); + } + + // Filter hosts by search + const filteredSshByFolder: Record = {}; + Object.entries(sshByFolder).forEach(([folder, hosts]) => { + filteredSshByFolder[folder] = hosts.filter(conn => { + const q = debouncedSearch.trim().toLowerCase(); + if (!q) return true; + return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q); + }); + }); + + // Folder input logic (copy from SSHSidebar) + const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); + React.useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + folderDropdownRef.current && + !folderDropdownRef.current.contains(event.target as Node) && + folderInputRef.current && + !folderInputRef.current.contains(event.target as Node) + ) { + setFolderDropdownOpen(false); + } + } + if (folderDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [folderDropdownOpen]); + + // --- Render --- + // Expect a prop: tabs: Tab[] + // Use: props.tabs + return ( - - - + + + - Icon + Icon - Termix / Config - - + + - - {/* Sidebar Items */} - - + + + + + + + + Add SSH + + Configure a new SSH connection for the config editor. + + +
+ {addSubmitError && ( +
{addSubmitError}
+ )} + +
+ ( + + Name + + + + + + )} + /> + ( + + Folder + + { + if (typeof field.ref === 'function') field.ref(el); + (folderInputRef as React.MutableRefObject).current = el; + }} + placeholder="e.g. Work" + autoComplete="off" + value={field.value} + onFocus={() => setFolderDropdownOpen(true)} + onChange={e => { + field.onChange(e); + setFolderDropdownOpen(true); + }} + disabled={foldersLoading} + /> + + {folderDropdownOpen && folders.length > 0 && ( +
+
+ {folders.map(folder => ( + + ))} +
+
+ )} + {foldersLoading && +
Loading folders...
} + {foldersError && +
{foldersError}
} + +
+ )} + /> +

Connection Details

+ +
+ ( + + Username + + + + + + )} + /> + ( + + IP Address + + + + + + )} + /> + ( + + Port + + + + + + )} + /> + ( + +

Authentication

+ + + + Password + SSH Key + + + ( + + Password + + + + + + )} + /> + + + { + const file = field.value as File | null; + return ( + + SSH Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+ +
+ ); + }} + /> + ( + + Key Password (if protected) + + + + + + )} + /> + { + const keyTypeOptions = [ + {value: 'auto', label: 'Auto-detect'}, + {value: 'ssh-rsa', label: 'RSA'}, + {value: 'ssh-ed25519', label: 'ED25519'}, + {value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'}, + {value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'}, + {value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'}, + {value: 'ssh-dss', label: 'DSA'}, + {value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'}, + {value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'}, + ]; + const [dropdownOpen, setDropdownOpen] = React.useState(false); + const dropdownRef = React.useRef(null); + const buttonRef = React.useRef(null); + React.useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setDropdownOpen(false); + } + } + if (dropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [dropdownOpen]); + return ( + + Key Type + +
+ + {dropdownOpen && ( +
+
+ {keyTypeOptions.map(opt => ( + + ))} +
+
+ )} +
+
+ +
+ ); + }} + /> +
+
+
+ )} + /> +

Other

+ + ( + + Default Path + + + + + + )} + /> + ( + + +
+ + Pin Connection +
+
+ +
+ )} + /> + +
+ + + + + + + + - + {/* Main black div: servers list or file/folder browser */} +
+ + {view === 'servers' && ( +
+ {/* SSH hosts/folders section, SSHSidebar-accurate */} +
+ {/* Search bar (full width, no border/rounded on container) */} +
+ setSearch(e.target.value)} + placeholder="Search hosts..." + className="w-full h-8 text-sm bg-background border border-border rounded" + autoComplete="off" + /> +
+
+ +
+ {/* Host list, centered, max width, no border/rounded on container */} +
+ {/* Local server */} +
+
handleSelectServer({ + isLocal: true, + name: 'Local Files', + id: 'local', + defaultPath: localDefaultPath + })} + style={{minWidth: 0}} + > + + Local Files +
+
+ +
+
+ {/* Accordion for folders/hosts */} +
+ + {sortedFolders.map((folder, idx) => ( + + + {folder} + + {filteredSshByFolder[folder].map(conn => ( +
+
+
+
handleSelectServer(conn)} + > +
+ {conn.isPinned && } + {conn.name || conn.ip} +
+
+
+ setSshPopoverOpen(prev => ({ + ...prev, + [conn.id]: open + }))}> + + + + + + + + +
+
+
+
+ ))} +
+
+ {idx < sortedFolders.length - 1 && ( +
+ +
+ )} +
+ ))} +
+
+
+
+
+ )} + {view === 'files' && activeServer && ( +
+ {/* Sticky path input bar */} +
+ + setCurrentPath(e.target.value)} + className="flex-1 bg-background border border-border text-white max-w-[170px] truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring" + style={{ background: '#18181b' }} + /> +
+ {/* File list with always-visible scrollbar, border at top */} +
+ +
+ {filesLoading ? ( +
Loading...
+ ) : filesError ? ( +
{filesError}
+ ) : files.length === 0 ? ( +
No files or folders found.
+ ) : ( +
+ {files.map((item: any) => { + const isOpen = (tabs || []).some((t: any) => t.id === item.path); + return ( +
+
!isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile(item))} + > + {item.type === 'directory' ? + : + } + {item.name} +
+
+ +
+
+ ); + })} +
+ )} +
+
+
+
+ )} +
+
+ {/* Add/Edit SSH Sheet (pixel-perfect copy of SSHSidebar Add Host sheet) */} + + + + Edit Local Files + + Set the default path for the Local Files browser. This will be used as the starting + directory. + + +
+
+ + + Default Path + + setLocalDefaultPath(e.target.value)} + placeholder="/home/user" + className="bg-[#18181b] border border-[#23232a] text-white rounded-md px-2 py-2 min-h-[40px] text-sm"/> + + +
+ +
+ + + +
+
- ) -} \ No newline at end of file + ); +}); +export { ConfigEditorSidebar }; \ No newline at end of file diff --git a/src/apps/Config Editor/ConfigFileSidebarViewer.tsx b/src/apps/Config Editor/ConfigFileSidebarViewer.tsx new file mode 100644 index 00000000..9076c089 --- /dev/null +++ b/src/apps/Config Editor/ConfigFileSidebarViewer.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Plus, Folder, File, Star, Trash2, Edit, Link2, Server } 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 ConfigFileSidebarViewer({ + sshConnections, + onAddSSH, + onConnectSSH, + onEditSSH, + onDeleteSSH, + onPinSSH, + currentPath, + files, + onOpenFile, + onOpenFolder, + onStarFile, + onDeleteFile, + isLoading, + error, + isSSHMode, + onSwitchToLocal, + onSwitchToSSH, + currentSSH, +}: ConfigFileSidebarViewerProps) { + return ( +
+ {/* SSH Connections */} +
+
+ SSH Connections + +
+
+ + {sshConnections.map((conn) => ( +
+ + + + +
+ ))} +
+
+ {/* File/Folder Viewer */} +
+
+ {isSSHMode ? 'SSH Path' : 'Local Path'} + {currentPath} +
+ {isLoading ? ( +
Loading...
+ ) : error ? ( +
{error}
+ ) : ( +
+ {files.map((item) => ( + +
item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}> + {item.type === 'directory' ? : } + {item.name} +
+
+ + +
+
+ ))} + {files.length === 0 &&
No files or folders found.
} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/apps/Config Editor/ConfigHomeView.tsx b/src/apps/Config Editor/ConfigHomeView.tsx new file mode 100644 index 00000000..c9bd038e --- /dev/null +++ b/src/apps/Config Editor/ConfigHomeView.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Star, Trash2, Folder, File, Plus } from 'lucide-react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Input } from '@/components/ui/input'; +import { useState } from 'react'; + +interface FileItem { + name: string; + path: string; + isStarred?: boolean; + type: 'file' | 'directory'; +} +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; + onOpenShortcut: (shortcut: ShortcutItem) => void; + onRemoveShortcut: (shortcut: ShortcutItem) => void; + onAddShortcut: (path: string) => void; +} + +export function ConfigHomeView({ + recent, + pinned, + shortcuts, + onOpenFile, + onRemoveRecent, + onPinFile, + onOpenShortcut, + onRemoveShortcut, + onAddShortcut, +}: ConfigHomeViewProps) { + const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent'); + const [newShortcut, setNewShortcut] = useState(''); + return ( +
+ setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full"> + + Recent + Pinned + Folder Shortcuts + + +
+ {recent.length === 0 ? ( + No recent files. + ) : recent.map((file, index) => ( + + + {file.name} + + + + ))} +
+
+ +
+ {pinned.length === 0 ? ( + No pinned files. + ) : pinned.map((file, index) => ( + + + {file.name} + + + ))} +
+
+ +
+ setNewShortcut(e.target.value)} + className="flex-1" + /> + +
+
+ {shortcuts.length === 0 ? ( + No shortcuts. + ) : shortcuts.map((shortcut, index) => ( + + + {shortcut.name || shortcut.path.split('/').pop()} + + + ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/apps/Config Editor/ConfigTabList.tsx b/src/apps/Config Editor/ConfigTabList.tsx new file mode 100644 index 00000000..249810aa --- /dev/null +++ b/src/apps/Config Editor/ConfigTabList.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +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 ConfigTabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }: ConfigTabListProps) { + return ( +
+ + {tabs.map((tab, index) => { + const isActive = tab.id === activeTab; + return ( +
+
+ + +
+
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/src/apps/Config Editor/ConfigTopbar.tsx b/src/apps/Config Editor/ConfigTopbar.tsx index 8d4975b0..61c422f8 100644 --- a/src/apps/Config Editor/ConfigTopbar.tsx +++ b/src/apps/Config Editor/ConfigTopbar.tsx @@ -1,16 +1,8 @@ import React from "react"; +import { ConfigTabList } from "./ConfigTabList"; -export function ConfigTopbar(): React.ReactElement { +export function ConfigTopbar(props: any): React.ReactElement { return ( -
- test -
+ ) } \ No newline at end of file diff --git a/src/apps/SSH/SSHSidebar.tsx b/src/apps/SSH/SSHSidebar.tsx index 3aeb58fc..06d925e9 100644 --- a/src/apps/SSH/SSHSidebar.tsx +++ b/src/apps/SSH/SSHSidebar.tsx @@ -816,16 +816,12 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa ( - Port + Username - field.onChange(Number(e.target.value) || 22)} - /> + @@ -834,12 +830,16 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa ( - Username + Port - + field.onChange(Number(e.target.value) || 22)} + /> @@ -1057,7 +1057,7 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa
{/* Search bar */} -
+
setSearch(e.target.value)} @@ -1066,7 +1066,9 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa autoComplete="off" />
- +
+ +
{/* Error and status messages */} {hostsError && (
@@ -1082,26 +1084,33 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa 0 ? sortedFolders : undefined}> {sortedFolders.map((folder, idx) => ( - - {folder} - - {getSortedHosts(hostsByFolder[folder]).map(host => ( -
- { - setEditHostData(host); - setEditHostOpen(true); - }} - popoverOpen={!!hostPopoverOpen[host.id]} - setPopoverOpen={(open: boolean) => handlePopoverOpenChange(host.id, open)} - /> -
- ))} -
-
+ + + {folder} + + {getSortedHosts(hostsByFolder[folder]).map(host => ( +
+ { + setEditHostData(host); + setEditHostOpen(true); + }} + popoverOpen={!!hostPopoverOpen[host.id]} + setPopoverOpen={(open: boolean) => handlePopoverOpenChange(host.id, open)} + /> +
+ ))} +
+
+ {idx < sortedFolders.length - 1 && ( +
+ +
+ )} +
))}
@@ -1315,16 +1324,12 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa /> ( - Port + Username - field.onChange(Number(e.target.value) || 22)} - /> + @@ -1332,12 +1337,16 @@ export function SSHSidebar({ onSelectView, onAddHostSubmit, onHostConnect, allTa /> ( - Username + Port - + field.onChange(Number(e.target.value) || 22)} + /> @@ -1690,10 +1699,10 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect, onD const hasTags = tags.length > 0; return (
-
+
{/* Left: Name + Star - Horizontal scroll only */} -
onHostConnect(host)} > @@ -1729,9 +1738,9 @@ const HostMenuItem = React.memo(function HostMenuItem({ host, onHostConnect, onD
{hasTags && ( -
+
{tags.map((tag: string) => ( - + {tag} ))} diff --git a/src/backend/config_editor/config_editor.ts b/src/backend/config_editor/config_editor.ts new file mode 100644 index 00000000..8d04dce2 --- /dev/null +++ b/src/backend/config_editor/config_editor.ts @@ -0,0 +1,290 @@ +import express from 'express'; +import cors from 'cors'; +import fs from 'fs'; +import path from 'path'; +import { Client as SSHClient } from 'ssh2'; +import chalk from "chalk"; + +const app = express(); +const PORT = 8084; + +app.use(cors({ + origin: 'http://localhost:5173', + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); +app.use(express.json()); + +const sshIconSymbol = '📁'; +const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); +const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { + return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`; +}; +const logger = { + info: (msg: string): void => { + console.log(formatMessage('info', chalk.cyan, msg)); + }, + warn: (msg: string): void => { + console.warn(formatMessage('warn', chalk.yellow, msg)); + }, + error: (msg: string, err?: unknown): void => { + console.error(formatMessage('error', chalk.redBright, msg)); + if (err) console.error(err); + }, + success: (msg: string): void => { + console.log(formatMessage('success', chalk.greenBright, msg)); + }, + debug: (msg: string): void => { + if (process.env.NODE_ENV !== 'production') { + console.debug(formatMessage('debug', chalk.magenta, msg)); + } + } +}; + +// --- Local File Operations --- +function normalizeFilePath(inputPath: string): string { + if (!inputPath || typeof inputPath !== 'string') throw new Error('Invalid path'); + let normalizedPath = inputPath.replace(/\\/g, '/'); + const windowsAbsPath = /^[a-zA-Z]:\//; + if (windowsAbsPath.test(normalizedPath)) return path.resolve(normalizedPath); + if (normalizedPath.startsWith('/')) return path.resolve(normalizedPath); + return path.resolve(process.cwd(), normalizedPath); +} +function isDirectory(p: string): boolean { + try { return fs.statSync(p).isDirectory(); } catch { return false; } +} + +app.get('/files', (req, res) => { + try { + const folderParam = req.query.folder as string || ''; + const folderPath = normalizeFilePath(folderParam); + if (!fs.existsSync(folderPath) || !isDirectory(folderPath)) { + logger.error('Directory not found:', folderPath); + return res.status(404).json({ error: 'Directory not found' }); + } + fs.readdir(folderPath, { withFileTypes: true }, (err, files) => { + if (err) { + logger.error('Error reading directory:', err); + return res.status(500).json({ error: err.message }); + } + const result = files.map(f => ({ name: f.name, type: f.isDirectory() ? 'directory' : 'file' })); + res.json(result); + }); + } catch (err: any) { + logger.error('Error in /files endpoint:', err); + res.status(400).json({ error: err.message }); + } +}); + +app.get('/file', (req, res) => { + try { + const folderParam = req.query.folder as string || ''; + const fileName = req.query.name as string; + if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' }); + const folderPath = normalizeFilePath(folderParam); + const filePath = path.join(folderPath, fileName); + if (!fs.existsSync(filePath)) { + logger.error(`File not found: ${filePath}`); + return res.status(404).json({ error: 'File not found' }); + } + if (isDirectory(filePath)) { + logger.error(`Path is a directory: ${filePath}`); + return res.status(400).json({ error: 'Path is a directory' }); + } + const content = fs.readFileSync(filePath, 'utf8'); + res.setHeader('Content-Type', 'text/plain'); + res.send(content); + } catch (err: any) { + logger.error('Error in /file GET endpoint:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/file', (req, res) => { + try { + const folderParam = req.query.folder as string || ''; + const fileName = req.query.name as string; + const content = req.body.content; + if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' }); + if (content === undefined) return res.status(400).json({ error: 'Missing "content" in request body' }); + const folderPath = normalizeFilePath(folderParam); + const filePath = path.join(folderPath, fileName); + if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + res.json({ message: 'File written successfully' }); + } catch (err: any) { + logger.error('Error in /file POST endpoint:', err); + res.status(500).json({ error: err.message }); + } +}); + +// --- SSH Operations (per-session, in-memory, with cleanup) --- +interface SSHSession { + client: SSHClient; + isConnected: boolean; + lastActive: number; + timeout?: NodeJS.Timeout; +} +const sshSessions: Record = {}; +const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes + +function cleanupSession(sessionId: string) { + const session = sshSessions[sessionId]; + if (session) { + try { session.client.end(); } catch {} + clearTimeout(session.timeout); + delete sshSessions[sessionId]; + logger.info(`Cleaned up SSH session: ${sessionId}`); + } +} +function scheduleSessionCleanup(sessionId: string) { + const session = sshSessions[sessionId]; + if (session) { + if (session.timeout) clearTimeout(session.timeout); + session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS); + } +} + +app.post('/ssh/connect', (req, res) => { + const { sessionId, ip, port, username, password, sshKey, keyPassword } = req.body; + if (!sessionId || !ip || !username || !port) { + logger.warn('Missing SSH connection parameters'); + return res.status(400).json({ error: 'Missing SSH connection parameters' }); + } + if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId); + const client = new SSHClient(); + const config: any = { + host: ip, port: port || 22, username, + readyTimeout: 20000, keepaliveInterval: 10000, keepaliveCountMax: 3, + }; + if (sshKey) { config.privateKey = sshKey; if (keyPassword) config.passphrase = keyPassword; } + else if (password) config.password = password; + else { logger.warn('No password or key provided'); return res.status(400).json({ error: 'Either password or SSH key must be provided' }); } + client.on('ready', () => { + sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now() }; + scheduleSessionCleanup(sessionId); + logger.info(`SSH connected: ${ip}:${port} as ${username} (session: ${sessionId})`); + res.json({ status: 'success', message: 'SSH connection established' }); + }); + client.on('error', (err) => { + logger.error('SSH connection error:', err.message); + res.status(500).json({ status: 'error', message: err.message }); + }); + client.on('close', () => { + if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false; + cleanupSession(sessionId); + }); + client.connect(config); +}); + +app.post('/ssh/disconnect', (req, res) => { + const { sessionId } = req.body; + cleanupSession(sessionId); + res.json({ status: 'success', message: 'SSH connection disconnected' }); +}); + +app.get('/ssh/status', (req, res) => { + const sessionId = req.query.sessionId as string; + const isConnected = !!sshSessions[sessionId]?.isConnected; + res.json({ status: 'success', connected: isConnected }); +}); + +app.get('/ssh/listFiles', (req, res) => { + const sessionId = req.query.sessionId as string; + const sshConn = sshSessions[sessionId]; + const { path: sshPath = '/' } = req.query; + if (!sshConn?.isConnected) { + logger.warn('SSH connection not established for session', sessionId); + return res.status(400).json({ error: 'SSH connection not established' }); + } + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + sshConn.client.exec(`ls -la "${sshPath}"`, (err, stream) => { + if (err) { logger.error('SSH listFiles error:', err); return res.status(500).json({ error: err.message }); } + let data = ''; + stream.on('data', (chunk: Buffer) => { data += chunk.toString(); }); + stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ }); + stream.on('close', () => { + const lines = data.split('\n').filter(line => line.trim()); + const files = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(/\s+/); + if (parts.length >= 9) { + const permissions = parts[0]; + const name = parts.slice(8).join(' '); + const isDirectory = permissions.startsWith('d'); + const isLink = permissions.startsWith('l'); + files.push({ name, type: isDirectory ? 'directory' : (isLink ? 'link' : 'file') }); + } + } + res.json(files); + }); + }); +}); + +app.get('/ssh/readFile', (req, res) => { + const sessionId = req.query.sessionId as string; + const sshConn = sshSessions[sessionId]; + const { path: filePath } = req.query; + if (!sshConn?.isConnected) { + logger.warn('SSH connection not established for session', sessionId); + return res.status(400).json({ error: 'SSH connection not established' }); + } + if (!filePath) { + logger.warn('File path is required for readFile'); + return res.status(400).json({ error: 'File path is required' }); + } + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + sshConn.client.exec(`cat "${filePath}"`, (err, stream) => { + if (err) { logger.error('SSH readFile error:', err); return res.status(500).json({ error: err.message }); } + let data = ''; + stream.on('data', (chunk: Buffer) => { data += chunk.toString(); }); + stream.stderr.on('data', (_chunk: Buffer) => { /* ignore for now */ }); + stream.on('close', () => { + res.json({ content: data, path: filePath }); + }); + }); +}); + +app.post('/ssh/writeFile', (req, res) => { + const { sessionId, path: filePath, content } = req.body; + const sshConn = sshSessions[sessionId]; + if (!sshConn?.isConnected) { + logger.warn('SSH connection not established for session', sessionId); + return res.status(400).json({ error: 'SSH connection not established' }); + } + if (!filePath) { + logger.warn('File path is required for writeFile'); + return res.status(400).json({ error: 'File path is required' }); + } + if (content === undefined) { + logger.warn('File content is required for writeFile'); + return res.status(400).json({ error: 'File content is required' }); + } + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + // Write to a temp file, then move + const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const safeContent = content.replace(/'/g, "'\"'\"'"); + sshConn.client.exec(`echo '${safeContent}' > "${tempFile}" && mv "${tempFile}" "${filePath}"`, (err, stream) => { + if (err) { logger.error('SSH writeFile error:', err); return res.status(500).json({ error: err.message }); } + stream.on('close', () => { + res.json({ message: 'File written successfully', path: filePath }); + }); + }); +}); + +process.on('SIGINT', () => { + Object.keys(sshSessions).forEach(cleanupSession); + process.exit(0); +}); + +process.on('SIGTERM', () => { + Object.keys(sshSessions).forEach(cleanupSession); + process.exit(0); +}); + +app.listen(PORT, () => {}); \ No newline at end of file diff --git a/src/backend/config_editor/old_database.js b/src/backend/config_editor/old_database.js new file mode 100644 index 00000000..b52e7774 --- /dev/null +++ b/src/backend/config_editor/old_database.js @@ -0,0 +1,401 @@ +const express = require('express'); +const http = require('http'); +const Database = require('better-sqlite3'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const cors = require("cors"); +const jwt = require('jsonwebtoken'); +require('dotenv').config(); + +const app = express(); +const PORT = 8081; + +app.use(cors({ + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); +app.use(express.json()); + +const getReadableTimestamp = () => { + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'medium', + timeZone: 'UTC', + }).format(new Date()); +}; + +const logger = { + info: (...args) => console.log(`💾 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args), + error: (...args) => console.error(`💾 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args), + warn: (...args) => console.warn(`💾 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args), + debug: (...args) => console.debug(`💾 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args) +}; + +const SALT = process.env.SALT || 'default_salt'; +const JWT_SECRET = SALT + '_jwt_secret'; +const DB_PATH = path.join(__dirname, 'data', 'users.db'); + +const dataDir = path.join(__dirname, 'data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.prepare(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + created_at TEXT NOT NULL, + is_admin BOOLEAN DEFAULT 0, + theme TEXT DEFAULT 'vscode' +)`).run(); + +db.prepare(`CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signup_enabled BOOLEAN DEFAULT 1 +)`).run(); + +const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; +if (settingsCount === 0) { + db.prepare('INSERT INTO settings (signup_enabled) VALUES (1)').run(); +} + +db.prepare(`CREATE TABLE IF NOT EXISTS user_recent_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + last_opened TEXT NOT NULL, + server_name TEXT, + server_ip TEXT, + server_port INTEGER, + server_user TEXT, + server_default_path TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +)`).run(); + +db.prepare(`CREATE TABLE IF NOT EXISTS user_starred_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + last_opened TEXT NOT NULL, + server_name TEXT, + server_ip TEXT, + server_port INTEGER, + server_user TEXT, + server_default_path TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +)`).run(); + +db.prepare(`CREATE TABLE IF NOT EXISTS user_folder_shortcuts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + folder_path TEXT NOT NULL, + folder_name TEXT NOT NULL, + server_name TEXT, + server_ip TEXT, + server_port INTEGER, + server_user TEXT, + server_default_path TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +)`).run(); + +db.prepare(`CREATE TABLE IF NOT EXISTS user_open_tabs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + tab_id TEXT NOT NULL, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL, + content TEXT, + saved_content TEXT, + is_dirty BOOLEAN DEFAULT 0, + server_name TEXT, + server_ip TEXT, + server_port INTEGER, + server_user TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +)`).run(); + +db.prepare(`CREATE TABLE IF NOT EXISTS user_current_path ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + current_path TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +)`).run(); + +db.prepare(`CREATE TABLE IF NOT EXISTS user_ssh_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + server_name TEXT NOT NULL, + server_ip TEXT NOT NULL, + server_port INTEGER DEFAULT 22, + username TEXT NOT NULL, + password TEXT, + ssh_key TEXT, + default_path TEXT DEFAULT '/', + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +)`).run(); + +function getKeyAndIV() { + const key = crypto.createHash('sha256').update(SALT).digest(); + const iv = Buffer.alloc(16, 0); + return { key, iv }; +} + +function encrypt(text) { + const { key, iv } = getKeyAndIV(); + const cipher = crypto.createCipheriv('aes-256-ctr', key, iv); + let crypted = cipher.update(text, 'utf8', 'hex'); + crypted += cipher.final('hex'); + return crypted; +} + +function decrypt(text) { + const { key, iv } = getKeyAndIV(); + const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv); + let dec = decipher.update(text, 'hex', 'utf8'); + dec += decipher.final('utf8'); + return dec; +} + +function generateToken(user) { + return jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' }); +} + +function authMiddleware(req, res, next) { + const authHeader = req.headers['authorization']; + if (!authHeader) return res.status(401).json({ error: 'No token provided' }); + const token = authHeader.split(' ')[1]; + if (!token) return res.status(401).json({ error: 'Invalid token format' }); + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = decoded; + next(); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +app.post('/register', async (req, res) => { + const { username, password } = req.body; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + + const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get(); + if (!settings.signup_enabled) { + return res.status(403).json({ error: 'Signups are currently disabled' }); + } + + try { + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + const isFirstUser = userCount === 0; + + const hash = await bcrypt.hash(password + SALT, 10); + const stmt = db.prepare('INSERT INTO users (username, password, created_at, is_admin) VALUES (?, ?, ?, ?)'); + stmt.run(username, encrypt(hash), new Date().toISOString(), isFirstUser ? 1 : 0); + + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + const token = generateToken(user); + return res.json({ + token, + user: { + id: user.id, + username: user.username, + isAdmin: user.is_admin === 1 + }, + isFirstUser + }); + } catch (err) { + if (err.code === 'SQLITE_CONSTRAINT') { + return res.status(409).json({ error: 'Username already exists' }); + } + logger.error('Registration error:', err); + return res.status(500).json({ error: 'Registration failed' }); + } +}); + +app.post('/login', async (req, res) => { + const { username, password } = req.body; + if (!username || !password) return res.status(401).json({ error: 'Username and password required' }); + try { + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + const hash = decrypt(user.password); + const valid = await bcrypt.compare(password + SALT, hash); + if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); + const token = generateToken(user); + return res.json({ + token, + user: { + id: user.id, + username: user.username, + isAdmin: user.is_admin === 1 + } + }); + } catch (err) { + logger.error('Login error:', err); + return res.status(500).json({ error: 'Login failed' }); + } +}); + +app.get('/profile', authMiddleware, (req, res) => { + const user = db.prepare('SELECT id, username, created_at, is_admin FROM users WHERE id = ?').get(req.user.id); + if (!user) return res.status(404).json({ error: 'User not found' }); + return res.json({ + user: { + id: user.id, + username: user.username, + created_at: user.created_at, + isAdmin: user.is_admin === 1 + } + }); +}); + +app.get('/check-first-user', (req, res) => { + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + return res.json({ isFirstUser: userCount === 0 }); +}); + +app.get('/admin/settings', authMiddleware, (req, res) => { + const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id); + if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' }); + + const settings = db.prepare('SELECT signup_enabled FROM settings WHERE id = 1').get(); + return res.json({ settings }); +}); + +app.post('/admin/settings', authMiddleware, (req, res) => { + const user = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(req.user.id); + if (!user || !user.is_admin) return res.status(403).json({ error: 'Admin access required' }); + + const { signup_enabled } = req.body; + if (typeof signup_enabled !== 'boolean') { + return res.status(400).json({ error: 'Invalid signup_enabled value' }); + } + + db.prepare('UPDATE settings SET signup_enabled = ? WHERE id = 1').run(signup_enabled ? 1 : 0); + return res.json({ message: 'Settings updated successfully' }); +}); + +app.use('/file', authMiddleware); +app.use('/files', authMiddleware); + +app.post('/user/data', authMiddleware, (req, res) => { + const { recentFiles, starredFiles, folderShortcuts, openTabs, currentPath, sshServers, theme } = req.body; + const userId = req.user.id; + + try { + db.prepare('BEGIN').run(); + + if (recentFiles) { + db.prepare('DELETE FROM user_recent_files WHERE user_id = ?').run(userId); + const stmt = db.prepare('INSERT INTO user_recent_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + recentFiles.forEach(file => { + stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath); + }); + } + + if (starredFiles) { + db.prepare('DELETE FROM user_starred_files WHERE user_id = ?').run(userId); + const stmt = db.prepare('INSERT INTO user_starred_files (user_id, file_path, file_name, last_opened, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + starredFiles.forEach(file => { + stmt.run(userId, file.path, file.name, file.lastOpened, file.serverName, file.serverIp, file.serverPort, file.serverUser, file.serverDefaultPath); + }); + } + + if (folderShortcuts) { + db.prepare('DELETE FROM user_folder_shortcuts WHERE user_id = ?').run(userId); + const stmt = db.prepare('INSERT INTO user_folder_shortcuts (user_id, folder_path, folder_name, server_name, server_ip, server_port, server_user, server_default_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + folderShortcuts.forEach(folder => { + stmt.run(userId, folder.path, folder.name, folder.serverName, folder.serverIp, folder.serverPort, folder.serverUser, folder.serverDefaultPath); + }); + } + + if (openTabs) { + db.prepare('DELETE FROM user_open_tabs WHERE user_id = ?').run(userId); + const stmt = db.prepare('INSERT INTO user_open_tabs (user_id, tab_id, file_name, file_path, content, saved_content, is_dirty, server_name, server_ip, server_port, server_user) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + openTabs.forEach(tab => { + stmt.run(userId, tab.id, tab.name, tab.path, tab.content || '', tab.savedContent || '', tab.isDirty ? 1 : 0, tab.serverName, tab.serverIp, tab.serverPort, tab.serverUser); + }); + } + + if (currentPath) { + db.prepare('INSERT OR REPLACE INTO user_current_path (user_id, current_path) VALUES (?, ?)').run(userId, currentPath); + } + + if (sshServers) { + db.prepare('DELETE FROM user_ssh_servers WHERE user_id = ?').run(userId); + const stmt = db.prepare('INSERT INTO user_ssh_servers (user_id, server_name, server_ip, server_port, username, password, ssh_key, default_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); + sshServers.forEach(server => { + stmt.run(userId, server.name, server.ip, server.port || 22, server.user, server.password ? encrypt(server.password) : null, server.sshKey ? encrypt(server.sshKey) : null, server.defaultPath || '/', server.createdAt || new Date().toISOString()); + }); + } + + if (theme) { + db.prepare('UPDATE users SET theme = ? WHERE id = ?').run(theme, userId); + } + + db.prepare('COMMIT').run(); + res.json({ message: 'User data saved successfully' }); + } catch (err) { + db.prepare('ROLLBACK').run(); + logger.error('Error saving user data:', err); + res.status(500).json({ error: 'Failed to save user data' }); + } +}); + +app.get('/user/data', authMiddleware, (req, res) => { + const userId = req.user.id; + + try { + const recentFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_recent_files WHERE user_id = ?').all(userId); + + const starredFiles = db.prepare('SELECT file_path as path, file_name as name, last_opened as lastOpened, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_starred_files WHERE user_id = ?').all(userId); + + const folderShortcuts = db.prepare('SELECT folder_path as path, folder_name as name, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser, server_default_path as serverDefaultPath FROM user_folder_shortcuts WHERE user_id = ?').all(userId); + + const openTabs = db.prepare('SELECT tab_id as id, file_name as name, file_path as path, content, saved_content as savedContent, is_dirty as isDirty, server_name as serverName, server_ip as serverIp, server_port as serverPort, server_user as serverUser FROM user_open_tabs WHERE user_id = ?').all(userId); + + const currentPath = db.prepare('SELECT current_path FROM user_current_path WHERE user_id = ?').get(userId); + + const sshServers = db.prepare('SELECT server_name as name, server_ip as ip, server_port as port, username as user, password, ssh_key as sshKey, default_path as defaultPath, created_at as createdAt FROM user_ssh_servers WHERE user_id = ?').all(userId); + + const decryptedServers = sshServers.map(server => ({ + ...server, + password: server.password ? decrypt(server.password) : null, + sshKey: server.sshKey ? decrypt(server.sshKey) : null + })); + + const userTheme = db.prepare('SELECT theme FROM users WHERE id = ?').get(userId)?.theme || 'vscode'; + + const data = { + recentFiles, + starredFiles, + folderShortcuts, + openTabs, + currentPath: currentPath?.current_path || '/', + sshServers: decryptedServers, + theme: userTheme + }; + res.json(data); + } catch (err) { + logger.error('Error loading user data:', err); + res.status(500).json({ error: 'Failed to load user data' }); + } +}); + +try { + db.prepare('ALTER TABLE users ADD COLUMN theme TEXT DEFAULT "vscode"').run(); +} catch (e) { + if (!e.message.includes('duplicate column')) throw e; +} + +app.listen(PORT, () => { + logger.info(`Database API listening at http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/src/backend/config_editor/old_file_manager.js b/src/backend/config_editor/old_file_manager.js new file mode 100644 index 00000000..9c76190c --- /dev/null +++ b/src/backend/config_editor/old_file_manager.js @@ -0,0 +1,141 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const cors = require('cors'); + +const app = express(); +const PORT = 8082; + +app.use(cors({ + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); +app.use(express.json()); + +const getReadableTimestamp = () => { + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'medium', + timeZone: 'UTC', + }).format(new Date()); +}; + +const logger = { + info: (...args) => console.log(`📁 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args), + error: (...args) => console.error(`📁 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args), + warn: (...args) => console.warn(`📁 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args), + debug: (...args) => console.debug(`📁 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args) +}; + +function normalizeFilePath(inputPath) { + if (!inputPath || typeof inputPath !== 'string') { + throw new Error('Invalid path'); + } + + let normalizedPath = inputPath.replace(/\\/g, '/'); + + const windowsAbsPath = /^[a-zA-Z]:\//; + if (windowsAbsPath.test(normalizedPath)) { + return path.resolve(normalizedPath); + } + + if (normalizedPath.startsWith('/')) { + return path.resolve(normalizedPath); + } + + return path.resolve(process.cwd(), normalizedPath); +} + +function isDirectory(path) { + try { + return fs.statSync(path).isDirectory(); + } catch (e) { + return false; + } +} + +app.get('/files', (req, res) => { + try { + const folderParam = req.query.folder || ''; + const folderPath = normalizeFilePath(folderParam); + + if (!fs.existsSync(folderPath) || !isDirectory(folderPath)) { + return res.status(404).json({ error: 'Directory not found' }); + } + + fs.readdir(folderPath, { withFileTypes: true }, (err, files) => { + if (err) { + logger.error('Error reading directory:', err); + return res.status(500).json({ error: err.message }); + } + + const result = files.map(f => ({ + name: f.name, + type: f.isDirectory() ? 'directory' : 'file', + })); + + res.json(result); + }); + } catch (err) { + logger.error('Error in /files endpoint:', err); + res.status(400).json({ error: err.message }); + } +}); + +app.get('/file', (req, res) => { + try { + const folderParam = req.query.folder || ''; + const fileName = req.query.name; + if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' }); + + const folderPath = normalizeFilePath(folderParam); + const filePath = path.join(folderPath, fileName); + + if (!fs.existsSync(filePath)) { + logger.error(`File not found: ${filePath}`); + return res.status(404).json({ error: 'File not found' }); + } + + if (isDirectory(filePath)) { + logger.error(`Path is a directory: ${filePath}`); + return res.status(400).json({ error: 'Path is a directory' }); + } + + const content = fs.readFileSync(filePath, 'utf8'); + res.setHeader('Content-Type', 'text/plain'); + res.send(content); + } catch (err) { + logger.error('Error in /file GET endpoint:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/file', (req, res) => { + try { + const folderParam = req.query.folder || ''; + const fileName = req.query.name; + const content = req.body.content; + + if (!fileName) return res.status(400).json({ error: 'Missing "name" parameter' }); + if (content === undefined) return res.status(400).json({ error: 'Missing "content" in request body' }); + + const folderPath = normalizeFilePath(folderParam); + const filePath = path.join(folderPath, fileName); + + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, { recursive: true }); + } + + fs.writeFileSync(filePath, content, 'utf8'); + res.json({ message: 'File written successfully' }); + } catch (err) { + logger.error('Error in /file POST endpoint:', err); + res.status(500).json({ error: err.message }); + } +}); + +app.listen(PORT, () => { + logger.info(`File manager API listening at http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/src/backend/config_editor/old_file_viewer.jsx b/src/backend/config_editor/old_file_viewer.jsx new file mode 100644 index 00000000..f0d9062f --- /dev/null +++ b/src/backend/config_editor/old_file_viewer.jsx @@ -0,0 +1,1068 @@ +import React, {useState, useEffect, useRef} from 'react'; +import {Button, Divider, Text, TextInput, Group, ScrollArea, Paper, Stack, ActionIcon, Modal, Loader} from "@mantine/core"; +import { ArrowUp, Folder, File, FolderOpen, Star, Server, Plus, Monitor, Edit, Trash2 } from 'lucide-react'; +import { SSHServerModal } from './SSHServerModal.jsx'; + +const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; +const isIPAddress = /^\d+\.\d+\.\d+\.\d+$/.test(window.location.hostname); + +const API_BASE = isLocalhost + ? `${window.location.protocol}//${window.location.hostname}:8082` + : isIPAddress + ? `${window.location.protocol}//${window.location.hostname}:${window.location.port}/fileviewer` + : `${window.location.protocol}//${window.location.hostname}:${window.location.port}/fileviewer`; + +const SSH_API_BASE = isLocalhost + ? `${window.location.protocol}//${window.location.hostname}:8083` + : isIPAddress + ? `${window.location.protocol}//${window.location.hostname}:${window.location.port}/ssh` + : `${window.location.protocol}//${window.location.hostname}:${window.location.port}/ssh`; + +const DB_API_BASE = isLocalhost + ? `${window.location.protocol}//${window.location.hostname}:8081` + : isIPAddress + ? `${window.location.protocol}//${window.location.hostname}:${window.location.port}/database` + : `${window.location.protocol}//${window.location.hostname}:${window.location.port}/database`; + +const CONFIG_FILE_EXTENSIONS = [ + '.json', '.yaml', '.yml', '.xml', '.ini', '.conf', '.config', + '.toml', '.env', '.properties', '.cfg', '.txt', '.md', '.log' +]; + +const LOCAL_SERVER = { + name: 'Local Container', + ip: 'local', + port: null, + user: null, + defaultPath: '/', + isLocal: true +}; + +export function FileViewer(props) { + const { onFileSelect, starredFiles, setStarredFiles, folder, setFolder, tabs, sshServers, setSSHServers, onSSHConnect, setCurrentServer, setTabState, setConnectingToServer, connectingToServer } = props; + const [files, setFiles] = useState([]); + const [message, setMessage] = useState(''); + const [configFiles, setConfigFiles] = useState([]); + const [currentServerState, setCurrentServerState] = useState(null); + const [isSSHMode, setIsSSHMode] = useState(false); + const [showSSHModal, setShowSSHModal] = useState(false); + const [editingServer, setEditingServer] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [localDefaultPath, setLocalDefaultPath] = useState('/'); + const [localContainerName, setLocalContainerName] = useState(LOCAL_SERVER.name); + const pathInputRef = useRef(null); + + const getLocalServer = () => ({ + ...LOCAL_SERVER, + name: localContainerName, + defaultPath: localDefaultPath + }); + + const handleBack = async () => { + const defaultPath = currentServerState?.defaultPath || localDefaultPath; + const normalize = p => (p || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '').toLowerCase(); + const normalizedFolder = normalize(folder); + let atRoot = false; + if (currentServerState?.isLocal && navigator.platform.includes('Win')) { + const driveRoot = (folder.split('/')[0] || 'C') + '/'; + atRoot = normalize(folder) === normalize(driveRoot); + } else { + atRoot = normalizedFolder === '' || normalizedFolder === '/'; + } + if (atRoot) { + return; + } + if (folder && folder !== '/') { + const normalizedPath = folder.replace(/\\/g, '/'); + const parts = normalizedPath.split('/').filter(Boolean); + if (parts.length > 0) { + parts.pop(); + let newPath; + if (currentServerState?.isLocal && navigator.platform.includes('Win')) { + let drive = parts[0] || 'C'; + if (drive.endsWith(':')) drive = drive.slice(0, -1); + newPath = parts.length > 0 ? drive + ':/' : (folder.split('/')[0] + '/'); + } else { + newPath = parts.length > 0 ? '/' + parts.join('/') : '/'; + } + setFolder(newPath); + } + } + }; + + const handleAddSSHServer = async (serverConfig) => { + try { + const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + }, + body: JSON.stringify({ + ip: serverConfig.ip, + port: serverConfig.port, + user: serverConfig.user, + password: serverConfig.password, + sshKey: serverConfig.sshKey + }) + }); + + if (!connectResponse.ok) { + const errorData = await connectResponse.json(); + throw new Error(errorData.message || 'Failed to connect to server'); + } + + await fetch(`${SSH_API_BASE}/sshDisconnect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + } + }); + + setSSHServers(prev => [...prev, serverConfig]); + } catch (error) { + throw error; + } + }; + + const handleEditSSHServer = async (oldServer, newServerConfig) => { + try { + if (oldServer.isLocal) { + setLocalDefaultPath(newServerConfig.defaultPath || '/'); + setLocalContainerName(newServerConfig.name || 'Local Container'); + localStorage.setItem('localDefaultPath', newServerConfig.defaultPath || '/'); + localStorage.setItem('localContainerName', newServerConfig.name || 'Local Container'); + return; + } + + const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + }, + body: JSON.stringify({ + ip: newServerConfig.ip, + port: newServerConfig.port, + user: newServerConfig.user, + password: newServerConfig.password, + sshKey: newServerConfig.sshKey + }) + }); + + if (!connectResponse.ok) { + const errorData = await connectResponse.json(); + throw new Error(errorData.message || 'Failed to connect to server'); + } + + await fetch(`${SSH_API_BASE}/sshDisconnect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + } + }); + + setSSHServers(prev => prev.map(server => + server.name === oldServer.name ? newServerConfig : server + )); + } catch (error) { + throw error; + } + }; + + const handleDeleteSSHServer = async (server) => { + try { + const updatedServers = sshServers.filter(s => s.name !== server.name); + + const response = await fetch(`${DB_API_BASE}/user/data`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + }, + body: JSON.stringify({ + sshServers: updatedServers + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to delete server'); + } + + setSSHServers(updatedServers); + setMessage('Server deleted successfully'); + } catch (error) { + setMessage(`Error deleting server: ${error.message}`); + } + }; + + const handleServerClick = async (server) => { + try { + setIsLoading(true); + setMessage('Connecting to server...'); + setConnectingToServer(server); + setCurrentServerState(server); + setIsSSHMode(true); + setFolder(server.defaultPath || '/'); + + let connected = false; + if (onSSHConnect) { + connected = await onSSHConnect(server); + } else { + const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + }, + body: JSON.stringify({ + ip: server.ip, + port: server.port, + user: server.user, + password: server.password, + sshKey: server.sshKey + }) + }); + + if (!connectResponse.ok) { + const errorData = await connectResponse.json(); + setMessage(`Failed to connect: ${errorData.message}`); + setIsSSHMode(false); + setCurrentServerState(null); + setConnectingToServer(null); + setIsLoading(false); + return; + } + connected = true; + } + + if (!connected) { + setMessage('Failed to connect to server'); + setIsSSHMode(false); + setCurrentServerState(null); + setConnectingToServer(null); + setIsLoading(false); + return; + } + + if (setCurrentServer) { + setCurrentServer(server); + } + setConnectingToServer(null); + + await loadSSHFiles(server.defaultPath || '/'); + setIsLoading(false); + } catch (error) { + setMessage(`Error connecting to server: ${error.message}`); + setIsSSHMode(false); + setCurrentServerState(null); + setConnectingToServer(null); + setIsLoading(false); + } + }; + + const handleLocalContainerClick = () => { + setIsSSHMode(false); + const localServer = getLocalServer(); + setCurrentServerState(localServer); + if (setCurrentServer) { + setCurrentServer(localServer); + } + const defaultPath = localDefaultPath; + setFolder(defaultPath); + + fetch(`${API_BASE}/files?folder=${encodeURIComponent(defaultPath)}`, { + headers: localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {} + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + setMessage(data.error); + setFiles([]); + } else { + setFiles(data); + setMessage(''); + } + }) + .catch(e => { + setMessage('Error loading folder: ' + e.message); + setFiles([]); + }); + }; + + const loadSSHFiles = async (path) => { + try { + setIsLoading(true); + const response = await fetch(`${SSH_API_BASE}/listFiles?path=${encodeURIComponent(path)}`, { + headers: localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {} + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to list files'); + } + + const data = await response.json(); + if (data.status === 'success') { + const filteredFiles = data.files.filter(file => file.name !== '.' && file.name !== '..'); + setFiles(filteredFiles); + setMessage(''); + } else { + throw new Error(data.message || 'Failed to list files'); + } + setIsLoading(false); + } catch (error) { + setMessage(`Error loading files: ${error.message}`); + setFiles([]); + setIsLoading(false); + } + }; + + const scanFolderForConfigs = async (folderPath) => { + try { + const response = await fetch(`${API_BASE}/files?folder=${encodeURIComponent(folderPath)}`); + const data = await response.json(); + + if (data.error) { + setMessage(data.error); + return; + } + + const configs = []; + for (const item of data) { + if (item.type === 'directory') { + const subConfigs = await scanFolderForConfigs(`${folderPath}/${item.name}`); + configs.push(...subConfigs); + } else if (CONFIG_FILE_EXTENSIONS.some(ext => item.name.toLowerCase().endsWith(ext))) { + configs.push({ + name: item.name, + path: `${folderPath}/${item.name}`, + type: 'file' + }); + } + } + return configs; + } catch (error) { + return []; + } + }; + + useEffect(() => { + setIsLoading(true); + setMessage(''); + if (isSSHMode && currentServerState) { + loadSSHFiles(folder); + } else if (!isSSHMode && folder) { + fetch(`${API_BASE}/files?folder=${encodeURIComponent(folder)}`, { + headers: localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {} + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + setMessage(data.error); + setFiles([]); + } else { + setFiles(data); + setMessage(''); + } + setIsLoading(false); + }) + .catch(e => { + setMessage('Error loading folder: ' + e.message); + setFiles([]); + setIsLoading(false); + }); + } else { + setFiles([]); + setIsLoading(false); + } + }, [folder, isSSHMode, currentServerState]); + + useEffect(() => { + const handleSaveFile = async (event) => { + const {content, folder, filename} = event.detail; + + if (isSSHMode && currentServerState) { + try { + const filePath = `${folder}/${filename}`.replace(/\\/g, '/').replace(/\/+/g, '/'); + const response = await fetch(`${SSH_API_BASE}/writeFile`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + }, + body: JSON.stringify({ + path: filePath, + content: content + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + setMessage(errorData.message || 'Failed to save file'); + } else { + setMessage('File saved successfully'); + } + } catch (error) { + setMessage('Error saving file: ' + error.message); + } + } else { + fetch(`${API_BASE}/file?folder=${encodeURIComponent(folder)}&name=${encodeURIComponent(filename)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + }, + body: JSON.stringify({content}), + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + setMessage(data.error); + } else { + setMessage(data.message || 'File saved successfully'); + } + }) + .catch(e => setMessage('Error writing file: ' + e.message)); + } + }; + + window.addEventListener('saveFile', handleSaveFile); + return () => window.removeEventListener('saveFile', handleSaveFile); + }, [isSSHMode, currentServerState]); + + const handleFileClick = async (name, type) => { + if (type === 'file') { + if (isSSHMode && currentServerState) { + const filePath = `${folder}/${name}`.replace(/\\/g, '/').replace(/\/+/g, '/'); + onFileSelect(name, folder, currentServerState, filePath); + } else { + onFileSelect(name, folder); + } + } else { + const newPath = folder.endsWith('/') ? folder + name : folder + '/' + name; + setFolder(newPath); + } + setMessage(''); + }; + + const connectToSSHServer = async (server) => { + try { + setIsLoading(true); + setMessage('Connecting to server...'); + setCurrentServerState(server); + setIsSSHMode(true); + + let connected = false; + if (onSSHConnect) { + connected = await onSSHConnect(server); + } else { + const connectResponse = await fetch(`${SSH_API_BASE}/sshConnect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + }, + body: JSON.stringify({ + ip: server.ip, + port: server.port, + user: server.user, + password: server.password, + sshKey: server.sshKey + }) + }); + + if (!connectResponse.ok) { + const errorData = await connectResponse.json(); + throw new Error(errorData.message || 'Failed to connect to server'); + } + connected = true; + } + + if (!connected) { + throw new Error('Failed to connect to server'); + } + + setIsLoading(false); + return true; + } catch (error) { + setMessage(`Error connecting to server: ${error.message}`); + setIsSSHMode(false); + setCurrentServerState(null); + setIsLoading(false); + return false; + } + }; + + const handleStarFile = (file) => { + const filePath = file.path || `${folder}/${file.name}`.replace(/\\/g, '/').replace(/\/+/g, '/'); + const fileInfo = { + name: file.name, + path: filePath, + lastOpened: new Date().toISOString(), + server: currentServerState ? { + name: currentServerState.name, + ip: currentServerState.ip, + port: currentServerState.port, + user: currentServerState.user + } : null + }; + const isStarred = starredFiles.some(f => f.path === filePath); + if (isStarred) { + setStarredFiles(starredFiles.filter(f => f.path !== filePath)); + } else { + setStarredFiles([...starredFiles, fileInfo]); + } + }; + + useEffect(() => { + if (pathInputRef.current) { + const input = pathInputRef.current; + input.scrollLeft = input.scrollWidth; + } + }, [folder]); + + useEffect(() => { + const savedPath = localStorage.getItem('localDefaultPath'); + const savedName = localStorage.getItem('localContainerName'); + if (savedPath) { + setLocalDefaultPath(savedPath); + } else { + setLocalDefaultPath('/'); + } + if (savedName) { + setLocalContainerName(savedName); + } + }, []); + + if (!isSSHMode && !currentServerState && (!folder || folder === '/')) { + return ( + + + + Available Servers + + + + {/* Local Container */} + e.currentTarget.style.backgroundColor = '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} + onClick={handleLocalContainerClick} + > + +
+ + {localContainerName} + + + {localDefaultPath} + +
+
+ { + e.stopPropagation(); + setEditingServer(getLocalServer()); + setShowSSHModal(true); + }} + > + + +
+
+ + {/* SSH Servers */} + {sshServers + .sort((a, b) => { + const aStarred = starredFiles.some(f => f.path === `ssh://${a.name}`); + const bStarred = starredFiles.some(f => f.path === `ssh://${b.name}`); + if (aStarred && !bStarred) return -1; + if (!aStarred && bStarred) return 1; + return a.name.localeCompare(b.name); + }) + .map((server, index) => { + const isStarred = starredFiles.some(f => f.path === `ssh://${server.name}`); + return ( + e.currentTarget.style.backgroundColor = '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} + onClick={() => handleServerClick(server)} + > + +
+ + {server.name} + + + {server.user}@{server.ip}:{server.port} + +
+
+ { + e.stopPropagation(); + setEditingServer(server); + setShowSSHModal(true); + }} + > + + + { + e.stopPropagation(); + handleDeleteSSHServer(server); + }} + > + + + { + e.stopPropagation(); + const serverPath = `ssh://${server.name}`; + const serverInfo = { + name: server.name, + path: serverPath, + lastOpened: new Date().toISOString(), + server: server + }; + const isCurrentlyStarred = starredFiles.some(f => f.path === serverPath); + if (isCurrentlyStarred) { + setStarredFiles(starredFiles.filter(f => f.path !== serverPath)); + } else { + setStarredFiles([...starredFiles, serverInfo]); + } + }} + > + {isStarred ? ( + + ) : ( + + )} + +
+
+ ); + })} + + {sshServers.length === 0 && ( + + No SSH servers added yet + + )} +
+
+ + + setShowSSHModal(true)} + style={{ + cursor: 'pointer', + backgroundColor: '#36414C', + border: '1px solid #4A5568', + userSelect: 'none', + transition: 'background 0.2s', + }} + onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} + > + + + Add SSH Server + + +
+
+ + { + setShowSSHModal(false); + setEditingServer(null); + }} + onAddServer={handleAddSSHServer} + onEditServer={handleEditSSHServer} + editingServer={editingServer} + /> +
+ ); + } + + return ( + + + + + {isSSHMode ? `SSH Path` : 'Local Machine Path'} + + { + setFolder(e.target.value); + }} + placeholder={isSSHMode ? "Enter SSH path e.g. /home/user" : "Enter folder path e.g. C:\\Users\\Luke or /Users/luke"} + styles={{ + input: { + backgroundColor: '#36414C', + borderColor: '#4A5568', + color: 'white', + '&::placeholder': { + color: '#A0AEC0' + } + } + }} + /> + + + + + + + {isSSHMode ? 'SSH File Manager' : 'File Manager'} + + + + + {(isLoading || connectingToServer) && ( + + + + {connectingToServer ? 'Loading...' : 'Loading...'} + + + )} + {!isLoading && !connectingToServer && files.length === 0 && !isSSHMode && ( + + No files found + + )} + {!isLoading && !connectingToServer && files.length === 0 && isSSHMode && ( + + No files found in SSH directory + + )} + {!isLoading && !connectingToServer && files.map(({name, type}) => { + const normalizedPath = `${folder}/${name}`.replace(/\\/g, '/').replace(/\/+/g, '/'); + const isOpen = type === 'file' && tabs.some(tab => tab.path === normalizedPath); + const isStarred = starredFiles.some(f => f.path === normalizedPath); + return ( + e.currentTarget.style.backgroundColor = isOpen ? '#23272f' : '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = isOpen ? '#23272f' : '#36414C'} + onClick={() => !isOpen && handleFileClick(name, type)} + > +
+ {type === 'directory' ? ( + + ) : ( + + )} + + {name} + +
+ {type === 'file' && ( +
+ { + e.stopPropagation(); + handleStarFile({ name, type, path: normalizedPath }); + }} + > + {isStarred ? ( + + ) : ( + + )} + +
+ )} +
+ ); + })} +
+
+ + {folder && ( + <> + + + e.currentTarget.style.backgroundColor = '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} + > + + + Back + + + { + setIsSSHMode(false); + setCurrentServerState(null); + setFolder('/'); + if (setCurrentServer) { + setCurrentServer(null); + } + if (setTabState) { + setTabState({ tabs: [], activeTab: 'home' }); + } + if (!currentServerState?.isLocal) { + fetch(`${SSH_API_BASE}/sshDisconnect`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(localStorage.getItem('token') ? { 'Authorization': `Bearer ${localStorage.getItem('token')}` } : {}) + } + }); + } + }} + style={{ + cursor: 'pointer', + backgroundColor: '#36414C', + border: '1px solid #4A5568', + userSelect: 'none', + transition: 'background 0.2s', + flex: 1, + }} + onMouseOver={e => e.currentTarget.style.backgroundColor = '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} + > + + + Servers + + + + + )} +
+
+
+ ); +} + +function StarHoverableIcon(props) { + const [hover, setHover] = useState(false); + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16 }} + > + {hover ? : } + + ); +} + +export { StarHoverableIcon }; \ No newline at end of file diff --git a/src/backend/config_editor/old_homeview.jsx b/src/backend/config_editor/old_homeview.jsx new file mode 100644 index 00000000..3f680a73 --- /dev/null +++ b/src/backend/config_editor/old_homeview.jsx @@ -0,0 +1,380 @@ +import React, { useState } from 'react'; +import { + Stack, + Paper, + Text, + Group, + Button, + ActionIcon, + ScrollArea, + TextInput, + Divider, + SimpleGrid, + Loader +} from '@mantine/core'; +import { + Star, + Folder, + File, + Trash2, + Plus, + History, + Bookmark, + Folders +} from 'lucide-react'; +import { StarHoverableIcon } from './FileViewer.jsx'; + +function compareServers(a, b) { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.isLocal && b.isLocal) return true; + return a.name === b.name && a.ip === b.ip && a.port === b.port && a.user === b.user; +} + +export function HomeView({ onFileSelect, recentFiles, starredFiles, setStarredFiles, folderShortcuts, setFolderShortcuts, setFolder, setActiveTab, handleRemoveRecent, onSSHConnect, currentServer, isSSHConnecting }) { + const [newFolderPath, setNewFolderPath] = useState(''); + const [activeSection, setActiveSection] = useState('recent'); + + const handleStarFile = (file) => { + const isStarred = starredFiles.some(f => f.path === file.path); + if (isStarred) { + setStarredFiles(starredFiles.filter(f => f.path !== file.path)); + } else { + setStarredFiles([...starredFiles, file]); + } + }; + + const handleRemoveStarred = (file) => { + setStarredFiles(starredFiles.filter(f => f.path !== file.path)); + }; + + const handleRemoveFolder = (folder) => { + setFolderShortcuts(folderShortcuts.filter(f => f.path !== folder.path)); + }; + + const handleAddFolder = () => { + if (!newFolderPath) return; + setFolderShortcuts([...folderShortcuts, { path: newFolderPath, name: newFolderPath.split('/').pop(), server: currentServer }]); + setNewFolderPath(''); + }; + + const getServerSpecificData = (data) => { + if (!currentServer) return []; + return data.filter(item => compareServers(item.server, currentServer)); + }; + + const serverRecentFiles = getServerSpecificData(recentFiles); + const serverStarredFiles = getServerSpecificData(starredFiles); + const serverFolderShortcuts = getServerSpecificData(folderShortcuts); + + const handleFileClick = async (file) => { + if (file.server && !file.server.isLocal) { + if (onSSHConnect && (!currentServer || !compareServers(currentServer, file.server))) { + const connected = await onSSHConnect(file.server); + if (!connected) { + return; + } + } + const pathParts = file.path.split('/').filter(Boolean); + const fileName = pathParts.pop() || ''; + const folderPath = '/' + pathParts.join('/'); + onFileSelect(fileName, folderPath, file.server, file.path); + } else { + let parentFolder; + if (navigator.platform.includes('Win') && file.path.includes(':')) { + const lastSlashIndex = file.path.lastIndexOf('/'); + if (lastSlashIndex === -1) { + const driveLetter = file.path.substring(0, file.path.indexOf(':') + 1); + parentFolder = driveLetter + '/'; + } else { + parentFolder = file.path.substring(0, lastSlashIndex + 1); + } + } else { + const lastSlashIndex = file.path.lastIndexOf('/'); + parentFolder = lastSlashIndex === -1 ? '/' : file.path.substring(0, lastSlashIndex + 1); + } + onFileSelect(file.name, parentFolder); + } + }; + + const FileItem = ({ file, onStar, onRemove, showRemove }) => { + const parentFolder = file.path.substring(0, file.path.lastIndexOf('/')) || '/'; + const isSSHFile = file.server; + + return ( + e.currentTarget.style.backgroundColor = '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} + onClick={() => handleFileClick(file)} + > +
+ +
+ + {file.name} + + {file.path} +
+
+
+ { + e.stopPropagation(); + onStar(file); + }} + > + {starredFiles.some(f => f.path === file.path) ? ( + + ) : ( + + )} + + {showRemove && ( + { + e.stopPropagation(); + onRemove(file); + }} + > + + + )} +
+
+ ); + }; + + const FolderItem = ({ folder, onRemove }) => ( + e.currentTarget.style.backgroundColor = '#4A5568'} + onMouseOut={e => e.currentTarget.style.backgroundColor = '#36414C'} + onClick={() => { + setFolder(folder.path); + }} + > + + +
+ {folder.name} + {folder.path} +
+ { + e.stopPropagation(); + onRemove(folder); + }} + > + + +
+
+ ); + + return ( + + {!currentServer && ( + + + Please select a server from the sidebar to view your files + + + )} + {currentServer && ( + <> + + + Connected to: {currentServer.name} ({currentServer.user}@{currentServer.ip}:{currentServer.port}) + + + {isSSHConnecting ? ( + + + + + Connecting to SSH server... + + + + ) : ( + <> + + + + + + {activeSection === 'recent' && ( +
+ + {serverRecentFiles.length === 0 ? ( + No recent files + ) : ( + serverRecentFiles.map(file => ( + + )) + )} + +
+ )} + {activeSection === 'starred' && ( +
+ + {serverStarredFiles.length === 0 ? ( + No starred files + ) : ( + serverStarredFiles.map(file => ( + + )) + )} + +
+ )} + {activeSection === 'folders' && ( + + + setNewFolderPath(e.target.value)} + style={{ flex: 1 }} + styles={{ + input: { + backgroundColor: '#36414C', + borderColor: '#4A5568', + color: 'white', + '&::placeholder': { + color: '#A0AEC0' + } + } + }} + /> + + + +
+ + {serverFolderShortcuts.length === 0 ? ( + No folder shortcuts + ) : ( + serverFolderShortcuts.map(folder => ( + + )) + )} + +
+
+ )} + + )} + + )} +
+ ); +} \ No newline at end of file diff --git a/src/backend/config_editor/old_ssh.js b/src/backend/config_editor/old_ssh.js new file mode 100644 index 00000000..c70ebfee --- /dev/null +++ b/src/backend/config_editor/old_ssh.js @@ -0,0 +1,358 @@ +const express = require('express'); +const http = require('http'); +const cors = require("cors"); +const bcrypt = require("bcrypt"); +const SSHClient = require("ssh2").Client; + +const app = express(); +const PORT = 8083; + +let sshConnection = null; +let isConnected = false; + +app.use(cors({ + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); +app.use(express.json()); + +const getReadableTimestamp = () => { + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'medium', + timeZone: 'UTC', + }).format(new Date()); +}; + +const logger = { + info: (...args) => console.log(`💻 | 🔧 [${getReadableTimestamp()}] INFO:`, ...args), + error: (...args) => console.error(`💻 | ❌ [${getReadableTimestamp()}] ERROR:`, ...args), + warn: (...args) => console.warn(`💻 | ⚠️ [${getReadableTimestamp()}] WARN:`, ...args), + debug: (...args) => console.debug(`💻 | 🔍 [${getReadableTimestamp()}] DEBUG:`, ...args) +}; + +const closeSSHConnection = () => { + if (sshConnection && isConnected) { + try { + sshConnection.end(); + sshConnection = null; + isConnected = false; + } catch (err) { + logger.error('Error closing SSH connection:', err.message); + } + } +}; + +const executeSSHCommand = (command) => { + return new Promise((resolve, reject) => { + if (!sshConnection || !isConnected) { + return reject(new Error('SSH connection not established')); + } + + sshConnection.exec(command, (err, stream) => { + if (err) { + logger.error('Error executing SSH command:', err.message); + return reject(err); + } + + let data = ''; + let error = ''; + + stream.on('data', (chunk) => { + data += chunk.toString(); + }); + + stream.stderr.on('data', (chunk) => { + error += chunk.toString(); + }); + + stream.on('close', (code) => { + if (code !== 0) { + logger.error(`SSH command failed with code ${code}:`, error); + return reject(new Error(`Command failed with code ${code}: ${error}`)); + } + resolve(data.trim()); + }); + }); + }); +}; + +app.post('/sshConnect', async (req, res) => { + try { + const hostConfig = req.body; + + if (!hostConfig || !hostConfig.ip || !hostConfig.user) { + return res.status(400).json({ + status: 'error', + message: 'Missing required host configuration (ip, user)' + }); + } + + closeSSHConnection(); + + sshConnection = new SSHClient(); + + const connectionConfig = { + host: hostConfig.ip, + port: hostConfig.port || 22, + username: hostConfig.user, + readyTimeout: 20000, + keepaliveInterval: 10000, + keepaliveCountMax: 3 + }; + + if (hostConfig.sshKey) { + connectionConfig.privateKey = hostConfig.sshKey; + } else if (hostConfig.password) { + connectionConfig.password = hostConfig.password; + } else { + return res.status(400).json({ + status: 'error', + message: 'Either password or SSH key must be provided' + }); + } + + sshConnection.on('ready', () => { + isConnected = true; + }); + + sshConnection.on('error', (err) => { + logger.error('SSH connection error:', err.message); + isConnected = false; + }); + + sshConnection.on('close', () => { + isConnected = false; + }); + + sshConnection.on('end', () => { + isConnected = false; + }); + + sshConnection.connect(connectionConfig); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('SSH connection timeout')); + }, 20000); + + sshConnection.once('ready', () => { + clearTimeout(timeout); + resolve(); + }); + + sshConnection.once('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + + return res.status(200).json({ + status: 'success', + message: 'SSH connection established successfully' + }); + + } catch (error) { + logger.error('SSH connection failed:', error.message); + closeSSHConnection(); + + return res.status(500).json({ + status: 'error', + message: `SSH connection failed: ${error.message}` + }); + } +}); + +app.get('/listFiles', async (req, res) => { + try { + const { path = '/' } = req.query; + + if (!sshConnection || !isConnected) { + return res.status(400).json({ + status: 'error', + message: 'SSH connection not established. Please connect first.' + }); + } + + const lsCommand = `ls -la "${path}"`; + const result = await executeSSHCommand(lsCommand); + + const lines = result.split('\n').filter(line => line.trim()); + const files = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(/\s+/); + + if (parts.length >= 9) { + const permissions = parts[0]; + const links = parseInt(parts[1]) || 0; + const owner = parts[2]; + const group = parts[3]; + const size = parseInt(parts[4]) || 0; + const month = parts[5]; + const day = parseInt(parts[6]) || 0; + const timeOrYear = parts[7]; + const name = parts.slice(8).join(' '); + + const isDirectory = permissions.startsWith('d'); + const isLink = permissions.startsWith('l'); + + files.push({ + name: name, + type: isDirectory ? 'directory' : (isLink ? 'link' : 'file'), + size: size, + permissions: permissions, + owner: owner, + group: group, + modified: `${month} ${day} ${timeOrYear}`, + isDirectory: isDirectory, + isLink: isLink + }); + } + } + + return res.status(200).json({ + status: 'success', + path: path, + files: files, + totalCount: files.length + }); + + } catch (error) { + logger.error('Error listing files:', error.message); + + return res.status(500).json({ + status: 'error', + message: `Failed to list files: ${error.message}` + }); + } +}); + +app.post('/sshDisconnect', async (req, res) => { + try { + closeSSHConnection(); + + return res.status(200).json({ + status: 'success', + message: 'SSH connection disconnected successfully' + }); + } catch (error) { + logger.error('Error disconnecting SSH:', error.message); + + return res.status(500).json({ + status: 'error', + message: `Failed to disconnect: ${error.message}` + }); + } +}); + +app.get('/sshStatus', async (req, res) => { + return res.status(200).json({ + status: 'success', + connected: isConnected, + hasConnection: !!sshConnection + }); +}); + +app.get('/readFile', async (req, res) => { + try { + const { path: filePath } = req.query; + + if (!sshConnection || !isConnected) { + return res.status(400).json({ + status: 'error', + message: 'SSH connection not established. Please connect first.' + }); + } + + if (!filePath) { + return res.status(400).json({ + status: 'error', + message: 'File path is required' + }); + } + + const catCommand = `cat "${filePath}"`; + const result = await executeSSHCommand(catCommand); + + return res.status(200).json({ + status: 'success', + content: result, + path: filePath + }); + + } catch (error) { + logger.error('Error reading file:', error.message); + + return res.status(500).json({ + status: 'error', + message: `Failed to read file: ${error.message}` + }); + } +}); + +app.post('/writeFile', async (req, res) => { + try { + const { path: filePath, content } = req.body; + + if (!sshConnection || !isConnected) { + return res.status(400).json({ + status: 'error', + message: 'SSH connection not established. Please connect first.' + }); + } + + if (!filePath) { + return res.status(400).json({ + status: 'error', + message: 'File path is required' + }); + } + + if (content === undefined) { + return res.status(400).json({ + status: 'error', + message: 'File content is required' + }); + } + + const tempFile = `/tmp/temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const echoCommand = `echo '${content.replace(/'/g, "'\"'\"'")}' > "${tempFile}"`; + await executeSSHCommand(echoCommand); + + const mvCommand = `mv "${tempFile}" "${filePath}"`; + await executeSSHCommand(mvCommand); + + return res.status(200).json({ + status: 'success', + message: 'File written successfully', + path: filePath + }); + + } catch (error) { + logger.error('Error writing file:', error.message); + + return res.status(500).json({ + status: 'error', + message: `Failed to write file: ${error.message}` + }); + } +}); + +process.on('SIGINT', () => { + closeSSHConnection(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + closeSSHConnection(); + process.exit(0); +}); + +app.listen(PORT, () => { + logger.info(`SSH API listening at http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/src/backend/config_editor/old_tablist.jsx b/src/backend/config_editor/old_tablist.jsx new file mode 100644 index 00000000..8b78d6ac --- /dev/null +++ b/src/backend/config_editor/old_tablist.jsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { Button } from '@mantine/core'; +import { Home } from 'lucide-react'; + +export function TabList({ tabs, activeTab, setActiveTab, closeTab, onHomeClick }) { + return ( +
+
+ +
+ +
+ {tabs.map((tab, i) => { + const isActive = tab.id === activeTab; + return ( +
+ +
+ +
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/backend/databasae/routes/config_editor.ts b/src/backend/databasae/routes/config_editor.ts new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/src/backend/databasae/routes/config_editor.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/backend/db/database.ts b/src/backend/database/database.ts similarity index 87% rename from src/backend/db/database.ts rename to src/backend/database/database.ts index 5b785cff..04d6d889 100644 --- a/src/backend/db/database.ts +++ b/src/backend/database/database.ts @@ -3,9 +3,19 @@ import bodyParser from 'body-parser'; import userRoutes from './routes/users.js'; import sshRoutes from './routes/ssh.js'; import sshTunnelRoutes from './routes/ssh_tunnel.js'; +import configEditorRoutes from './routes/config_editor.js'; import chalk from 'chalk'; import cors from 'cors'; +// CORS for local dev +const app = express(); +app.use(cors({ + origin: 'http://localhost:5173', + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); + // Custom logger (adapted from starter.ts, with a database icon) const dbIconSymbol = '🗄️'; const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); @@ -33,13 +43,6 @@ const logger = { } }; -const app = express(); - -app.use(cors({ - origin: 'http://localhost:5173', - credentials: true -})); - app.use(bodyParser.json()); app.get('/health', (req, res) => { @@ -49,6 +52,7 @@ app.get('/health', (req, res) => { app.use('/users', userRoutes); app.use('/ssh', sshRoutes); app.use('/ssh_tunnel', sshTunnelRoutes); +app.use('/config_editor', configEditorRoutes); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { logger.error('Unhandled error:', err); diff --git a/src/backend/db/db/index.ts b/src/backend/database/db/index.ts similarity index 81% rename from src/backend/db/db/index.ts rename to src/backend/database/db/index.ts index d1ed20d3..24861db6 100644 --- a/src/backend/db/db/index.ts +++ b/src/backend/database/db/index.ts @@ -37,7 +37,6 @@ if (!fs.existsSync(dbDir)) { } const sqlite = new Database('./db/data/db.sqlite'); -logger.success('Database connection established'); sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( @@ -62,6 +61,26 @@ CREATE TABLE IF NOT EXISTS ssh_data ( key_type TEXT, save_auth_method INTEGER, is_pinned INTEGER, + default_path TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) +); +CREATE TABLE IF NOT EXISTS config_ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + folder TEXT, + tags TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT, + password TEXT, + auth_method TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + save_auth_method INTEGER, + is_pinned INTEGER, + default_path TEXT, FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS ssh_tunnel_data ( @@ -98,6 +117,18 @@ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS config_editor_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT, + path TEXT NOT NULL, + server TEXT, + last_opened TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) +); `); try { sqlite.prepare('SELECT is_admin FROM users LIMIT 1').get(); diff --git a/src/backend/db/db/schema.ts b/src/backend/database/db/schema.ts similarity index 67% rename from src/backend/db/db/schema.ts rename to src/backend/database/db/schema.ts index 71659ee9..b4c2cf42 100644 --- a/src/backend/db/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -23,6 +23,7 @@ export const sshData = sqliteTable('ssh_data', { keyType: text('key_type'), // Type of SSH key (RSA, ED25519, etc.) saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }), isPinned: integer('is_pinned', { mode: 'boolean' }), + defaultPath: text('default_path'), // Default path for SSH connection }); export const sshTunnelData = sqliteTable('ssh_tunnel_data', { @@ -58,4 +59,35 @@ export const sshTunnelData = sqliteTable('ssh_tunnel_data', { export const settings = sqliteTable('settings', { key: text('key').primaryKey(), value: text('value').notNull(), +}); + +export const configEditorData = sqliteTable('config_editor_data', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id), + type: text('type').notNull(), // 'recent' | 'pinned' | 'shortcut' + name: text('name'), + path: text('path').notNull(), + server: text('server', { length: 2048 }), // JSON stringified server info (if SSH) + lastOpened: text('last_opened'), // ISO string (for recent) + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); + +export const configSshData = sqliteTable('config_ssh_data', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id), + name: text('name'), + folder: text('folder'), + tags: text('tags'), + ip: text('ip').notNull(), + port: integer('port').notNull(), + username: text('username'), + password: text('password'), + authMethod: text('auth_method'), + key: text('key', { length: 8192 }), + keyPassword: text('key_password'), + keyType: text('key_type'), + saveAuthMethod: integer('save_auth_method', { mode: 'boolean' }), + isPinned: integer('is_pinned', { mode: 'boolean' }), + defaultPath: text('default_path'), }); \ No newline at end of file diff --git a/src/backend/database/routes/config_editor.ts b/src/backend/database/routes/config_editor.ts new file mode 100644 index 00000000..e7c48e9b --- /dev/null +++ b/src/backend/database/routes/config_editor.ts @@ -0,0 +1,317 @@ +import express from 'express'; +import { db } from '../db/index.js'; +import { configEditorData, configSshData } from '../db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import type { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +const router = express.Router(); + +// --- JWT Auth Middleware --- +interface JWTPayload { + userId: string; + iat?: number; + exp?: number; +} +function authenticateJWT(req: Request, res: Response, next: NextFunction) { + const authHeader = req.headers['authorization']; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing or invalid Authorization header' }); + } + const token = authHeader.split(' ')[1]; + const jwtSecret = process.env.JWT_SECRET || 'secret'; + try { + const payload = jwt.verify(token, jwtSecret) as JWTPayload; + (req as any).userId = payload.userId; + next(); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +// --- Config Data Endpoints (DB-backed, per user) --- +router.get('/recent', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'recent'))); + res.json(data); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch recent files' }); + } +}); +router.post('/recent', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { name, path: filePath, server, lastOpened } = req.body; + if (!filePath) return res.status(400).json({ error: 'Missing path' }); + try { + const now = new Date().toISOString(); + await db.insert(configEditorData).values({ + userId, + type: 'recent', + name, + path: filePath, + server: server ? JSON.stringify(server) : null, + lastOpened: lastOpened || now, + createdAt: now, + updatedAt: now, + }); + res.json({ message: 'Added to recent' }); + } catch (err) { + res.status(500).json({ error: 'Failed to add to recent' }); + } +}); +router.get('/pinned', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'pinned'))); + res.json(data); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch pinned files' }); + } +}); +router.post('/pinned', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { name, path: filePath, server } = req.body; + if (!filePath) return res.status(400).json({ error: 'Missing path' }); + try { + const now = new Date().toISOString(); + await db.insert(configEditorData).values({ + userId, + type: 'pinned', + name, + path: filePath, + server: server ? JSON.stringify(server) : null, + createdAt: now, + updatedAt: now, + }); + res.json({ message: 'Added to pinned' }); + } catch (err) { + res.status(500).json({ error: 'Failed to add to pinned' }); + } +}); +router.get('/shortcuts', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const data = await db.select().from(configEditorData).where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'))); + res.json(data); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch shortcuts' }); + } +}); +router.post('/shortcuts', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { name, path: folderPath, server } = req.body; + if (!folderPath) return res.status(400).json({ error: 'Missing path' }); + try { + const now = new Date().toISOString(); + await db.insert(configEditorData).values({ + userId, + type: 'shortcut', + name, + path: folderPath, + server: server ? JSON.stringify(server) : null, + createdAt: now, + updatedAt: now, + }); + res.json({ message: 'Added to shortcuts' }); + } catch (err) { + res.status(500).json({ error: 'Failed to add to shortcuts' }); + } +}); + +// DELETE /config_editor/shortcuts +router.delete('/shortcuts', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { path } = req.body; + if (!path) return res.status(400).json({ error: 'Missing path' }); + try { + await db.delete(configEditorData) + .where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path))); + res.json({ message: 'Shortcut removed' }); + } catch (err) { + res.status(500).json({ error: 'Failed to remove shortcut' }); + } +}); +// POST /config_editor/shortcuts/delete (for compatibility) +router.post('/shortcuts/delete', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { path } = req.body; + if (!path) return res.status(400).json({ error: 'Missing path' }); + try { + await db.delete(configEditorData) + .where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'shortcut'), eq(configEditorData.path, path))); + res.json({ message: 'Shortcut removed' }); + } catch (err) { + res.status(500).json({ error: 'Failed to remove shortcut' }); + } +}); + +// --- Local Default Path Endpoints --- +// GET /config_editor/local_default_path +router.get('/local_default_path', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const row = await db.select().from(configEditorData) + .where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path'))) + .then(rows => rows[0]); + res.json({ defaultPath: row?.path || '/' }); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch local default path' }); + } +}); +// POST /config_editor/local_default_path +router.post('/local_default_path', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { defaultPath } = req.body; + if (!defaultPath) return res.status(400).json({ error: 'Missing defaultPath' }); + try { + const now = new Date().toISOString(); + // Upsert: delete old, insert new + await db.delete(configEditorData) + .where(and(eq(configEditorData.userId, userId), eq(configEditorData.type, 'local_default_path'))); + await db.insert(configEditorData).values({ + userId, + type: 'local_default_path', + name: 'Local Files', + path: defaultPath, + createdAt: now, + updatedAt: now, + }); + res.json({ message: 'Local default path saved' }); + } catch (err) { + res.status(500).json({ error: 'Failed to save local default path' }); + } +}); + +// --- SSH Connection CRUD for Config Editor --- +// GET /config_editor/ssh/host +router.get('/ssh/host', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + if (!userId) { + return res.status(400).json({ error: 'Invalid userId' }); + } + try { + const data = await db.select().from(configSshData).where(eq(configSshData.userId, userId)); + res.json(data); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch SSH hosts' }); + } +}); +// POST /config_editor/ssh/host +router.post('/ssh/host', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body; + if (!userId || !ip || !port) { + return res.status(400).json({ error: 'Invalid SSH data' }); + } + const sshDataObj: any = { + userId, + name, + folder, + tags: Array.isArray(tags) ? tags.join(',') : tags, + ip, + port, + username, + authMethod, + isPinned: isPinned ? 1 : 0, + defaultPath: defaultPath || null, + }; + if (authMethod === 'password') { + sshDataObj.password = password; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else if (authMethod === 'key') { + sshDataObj.key = sshKey; + sshDataObj.keyPassword = keyPassword; + sshDataObj.keyType = keyType; + sshDataObj.password = null; + } + try { + await db.insert(configSshData).values(sshDataObj); + res.json({ message: 'SSH host created' }); + } catch (err) { + res.status(500).json({ error: 'Failed to create SSH host' }); + } +}); +// PUT /config_editor/ssh/host/:id +router.put('/ssh/host/:id', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { id } = req.params; + const { name, folder, tags, ip, port, username, password, sshKey, keyPassword, keyType, isPinned, defaultPath, authMethod } = req.body; + if (!userId || !ip || !port || !id) { + return res.status(400).json({ error: 'Invalid SSH data' }); + } + const sshDataObj: any = { + name, + folder, + tags: Array.isArray(tags) ? tags.join(',') : tags, + ip, + port, + username, + authMethod, + isPinned: isPinned ? 1 : 0, + defaultPath: defaultPath || null, + }; + if (authMethod === 'password') { + sshDataObj.password = password; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else if (authMethod === 'key') { + sshDataObj.key = sshKey; + sshDataObj.keyPassword = keyPassword; + sshDataObj.keyType = keyType; + sshDataObj.password = null; + } + try { + await db.update(configSshData) + .set(sshDataObj) + .where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId))); + res.json({ message: 'SSH host updated' }); + } catch (err) { + res.status(500).json({ error: 'Failed to update SSH host' }); + } +}); + +// --- SSH Connection CRUD (reuse /ssh/host endpoints, or proxy) --- +router.delete('/ssh/host/:id', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const { id } = req.params; + if (!userId || !id) { + return res.status(400).json({ error: 'Invalid userId or id' }); + } + try { + await db.delete(configSshData) + .where(and(eq(configSshData.id, Number(id)), eq(configSshData.userId, userId))); + res.json({ message: 'SSH host deleted' }); + } catch (err) { + res.status(500).json({ error: 'Failed to delete SSH host' }); + } +}); + +// GET /config_editor/ssh/folders +router.get('/ssh/folders', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + if (!userId) { + return res.status(400).json({ error: 'Invalid userId' }); + } + try { + const data = await db + .select({ folder: configSshData.folder }) + .from(configSshData) + .where(eq(configSshData.userId, userId)); + const folderCounts: Record = {}; + data.forEach(d => { + if (d.folder && d.folder.trim() !== '') { + folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1; + } + }); + const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0); + res.json(folders); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch SSH folders' }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/backend/db/routes/ssh.ts b/src/backend/database/routes/ssh.ts similarity index 96% rename from src/backend/db/routes/ssh.ts rename to src/backend/database/routes/ssh.ts index f287f88d..23a7e81b 100644 --- a/src/backend/db/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -69,7 +69,7 @@ function authenticateJWT(req: Request, res: Response, next: NextFunction) { // Route: Create SSH data (requires JWT) // POST /ssh/host router.post('/host', authenticateJWT, async (req: Request, res: Response) => { - const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body; + const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body; const userId = (req as any).userId; if (!isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port)) { logger.warn('Invalid SSH data input'); @@ -87,6 +87,7 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => { authMethod, saveAuthMethod: saveAuthMethod ? 1 : 0, isPinned: isPinned ? 1 : 0, + defaultPath: defaultPath || null, }; if (saveAuthMethod) { @@ -120,7 +121,7 @@ router.post('/host', authenticateJWT, async (req: Request, res: Response) => { // Route: Update SSH data (requires JWT) // PUT /ssh/host/:id router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => { - const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned } = req.body; + const { name, folder, tags, ip, port, username, password, authMethod, key, keyPassword, keyType, saveAuthMethod, isPinned, defaultPath } = req.body; const { id } = req.params; const userId = (req as any).userId; @@ -139,6 +140,7 @@ router.put('/host/:id', authenticateJWT, async (req: Request, res: Response) => authMethod, saveAuthMethod: saveAuthMethod ? 1 : 0, isPinned: isPinned ? 1 : 0, + defaultPath: defaultPath || null, }; if (saveAuthMethod) { diff --git a/src/backend/db/routes/ssh_tunnel.ts b/src/backend/database/routes/ssh_tunnel.ts similarity index 100% rename from src/backend/db/routes/ssh_tunnel.ts rename to src/backend/database/routes/ssh_tunnel.ts diff --git a/src/backend/db/routes/users.ts b/src/backend/database/routes/users.ts similarity index 100% rename from src/backend/db/routes/users.ts rename to src/backend/database/routes/users.ts diff --git a/src/backend/ssh_tunnel/ssh_tunnel.ts b/src/backend/ssh_tunnel/ssh_tunnel.ts index 509ff4a1..96f607cb 100644 --- a/src/backend/ssh_tunnel/ssh_tunnel.ts +++ b/src/backend/ssh_tunnel/ssh_tunnel.ts @@ -1082,7 +1082,7 @@ app.delete('/tunnel/:name', (req, res) => { }); // Start the server -const PORT = process.env.SSH_TUNNEL_PORT || 8083; +const PORT = 8083; app.listen(PORT, () => { // Initialize auto-start tunnels after a short delay setTimeout(() => { diff --git a/src/backend/starter.ts b/src/backend/starter.ts index d2970806..b76bdf2f 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -1,9 +1,10 @@ // npx tsc -p tsconfig.node.json // node ./dist/backend/starter.js -import './db/database.js' +import './database/database.js' import './ssh/ssh.js'; import './ssh_tunnel/ssh_tunnel.js'; +import './config_editor/config_editor.js'; import chalk from 'chalk'; const fixedIconSymbol = '🚀';