361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
|
|
import { useEffect, useRef, useState } from 'react'
|
||
|
|
import {
|
||
|
|
Folder,
|
||
|
|
File as FileIcon,
|
||
|
|
ChevronRight,
|
||
|
|
Upload,
|
||
|
|
FolderPlus,
|
||
|
|
Trash2,
|
||
|
|
Download,
|
||
|
|
Pencil,
|
||
|
|
Save,
|
||
|
|
X,
|
||
|
|
RefreshCw,
|
||
|
|
} from 'lucide-react'
|
||
|
|
import { api, type FileEntry, type Integration } from '../lib/api'
|
||
|
|
|
||
|
|
const TEXT_PRIMARY = '#E8E6E0'
|
||
|
|
const TEXT_SECONDARY = '#7A7D85'
|
||
|
|
const GOLD = '#C8A434'
|
||
|
|
|
||
|
|
const cardBase: React.CSSProperties = {
|
||
|
|
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||
|
|
border: '1px solid rgba(200, 164, 52, 0.08)',
|
||
|
|
borderRadius: '12px',
|
||
|
|
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
|
||
|
|
}
|
||
|
|
|
||
|
|
function inputStyle(): React.CSSProperties {
|
||
|
|
return {
|
||
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
||
|
|
borderRadius: '6px',
|
||
|
|
padding: '6px 10px',
|
||
|
|
color: TEXT_PRIMARY,
|
||
|
|
fontSize: '13px',
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatSize(bytes: number): string {
|
||
|
|
if (bytes < 1024) return `${bytes} B`
|
||
|
|
const units = ['KB', 'MB', 'GB', 'TB']
|
||
|
|
let v = bytes
|
||
|
|
let i = -1
|
||
|
|
do {
|
||
|
|
v /= 1024
|
||
|
|
i++
|
||
|
|
} while (v >= 1024 && i < units.length - 1)
|
||
|
|
return `${v.toFixed(1)} ${units[i]}`
|
||
|
|
}
|
||
|
|
|
||
|
|
function joinPath(dir: string, name: string): string {
|
||
|
|
if (dir === '.' || dir === '') return name
|
||
|
|
return `${dir.replace(/\/$/, '')}/${name}`
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function Files() {
|
||
|
|
const [hosts, setHosts] = useState<Integration[]>([])
|
||
|
|
const [integrationId, setIntegrationId] = useState<number | ''>('')
|
||
|
|
const [path, setPath] = useState('.')
|
||
|
|
const [entries, setEntries] = useState<FileEntry[]>([])
|
||
|
|
const [error, setError] = useState<string | null>(null)
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
|
||
|
|
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||
|
|
const [editingContent, setEditingContent] = useState('')
|
||
|
|
const [editingEncoding, setEditingEncoding] = useState<'utf8' | 'base64'>('utf8')
|
||
|
|
const [savingEdit, setSavingEdit] = useState(false)
|
||
|
|
|
||
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
api.listIntegrations().then(({ integrations }) => {
|
||
|
|
const sshHosts = integrations.filter((i) => i.type === 'ssh')
|
||
|
|
setHosts(sshHosts)
|
||
|
|
if (sshHosts.length > 0) setIntegrationId(sshHosts[0].id)
|
||
|
|
})
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
function refresh() {
|
||
|
|
if (!integrationId) return
|
||
|
|
setLoading(true)
|
||
|
|
setError(null)
|
||
|
|
api
|
||
|
|
.listFiles(integrationId, path)
|
||
|
|
.then(({ entries }) => setEntries(entries))
|
||
|
|
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to list directory'))
|
||
|
|
.finally(() => setLoading(false))
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
refresh()
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
|
|
}, [integrationId, path])
|
||
|
|
|
||
|
|
function openDirectory(name: string) {
|
||
|
|
setPath(joinPath(path, name))
|
||
|
|
}
|
||
|
|
|
||
|
|
function goUp() {
|
||
|
|
if (path === '.' || path === '') return
|
||
|
|
const parts = path.split('/').filter(Boolean)
|
||
|
|
parts.pop()
|
||
|
|
setPath(parts.length === 0 ? '.' : parts.join('/'))
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openFile(name: string) {
|
||
|
|
if (!integrationId) return
|
||
|
|
const full = joinPath(path, name)
|
||
|
|
setError(null)
|
||
|
|
try {
|
||
|
|
const result = await api.readFile(integrationId, full)
|
||
|
|
setEditingPath(full)
|
||
|
|
setEditingContent(result.content)
|
||
|
|
setEditingEncoding(result.encoding)
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : 'Failed to open file')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveEdit() {
|
||
|
|
if (!integrationId || !editingPath) return
|
||
|
|
setSavingEdit(true)
|
||
|
|
try {
|
||
|
|
await api.writeFile(integrationId, editingPath, editingContent, editingEncoding)
|
||
|
|
setEditingPath(null)
|
||
|
|
refresh()
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : 'Failed to save file')
|
||
|
|
} finally {
|
||
|
|
setSavingEdit(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleMkdir() {
|
||
|
|
if (!integrationId) return
|
||
|
|
const name = prompt('New folder name')
|
||
|
|
if (!name) return
|
||
|
|
try {
|
||
|
|
await api.mkdir(integrationId, joinPath(path, name))
|
||
|
|
refresh()
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : 'Failed to create directory')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleRename(entry: FileEntry) {
|
||
|
|
if (!integrationId) return
|
||
|
|
const next = prompt('Rename to', entry.name)
|
||
|
|
if (!next || next === entry.name) return
|
||
|
|
try {
|
||
|
|
await api.renameFile(integrationId, joinPath(path, entry.name), joinPath(path, next))
|
||
|
|
refresh()
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : 'Failed to rename')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleDelete(entry: FileEntry) {
|
||
|
|
if (!integrationId) return
|
||
|
|
if (!confirm(`Delete ${entry.name}?`)) return
|
||
|
|
try {
|
||
|
|
await api.deleteFile(integrationId, joinPath(path, entry.name), entry.isDirectory)
|
||
|
|
refresh()
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : 'Failed to delete')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDownload(entry: FileEntry) {
|
||
|
|
if (!integrationId) return
|
||
|
|
window.open(api.downloadFileUrl(integrationId, joinPath(path, entry.name)), '_blank')
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleUpload(file: File) {
|
||
|
|
if (!integrationId) return
|
||
|
|
setError(null)
|
||
|
|
try {
|
||
|
|
await api.uploadFile(integrationId, path, file)
|
||
|
|
refresh()
|
||
|
|
} catch (err) {
|
||
|
|
setError(err instanceof Error ? err.message : 'Failed to upload file')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const breadcrumbs = path === '.' || path === '' ? [] : path.split('/').filter(Boolean)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="p-6 space-y-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-xl font-semibold" style={{ color: TEXT_PRIMARY }}>
|
||
|
|
Files
|
||
|
|
</h1>
|
||
|
|
<p className="text-sm" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
Browse, edit, and transfer files on your remote SSH hosts.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<select
|
||
|
|
style={inputStyle()}
|
||
|
|
value={integrationId}
|
||
|
|
onChange={(e) => {
|
||
|
|
setIntegrationId(e.target.value ? Number(e.target.value) : '')
|
||
|
|
setPath('.')
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{hosts.length === 0 && <option value="">No SSH hosts configured</option>}
|
||
|
|
{hosts.map((h) => (
|
||
|
|
<option key={h.id} value={h.id}>
|
||
|
|
{h.name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<div style={{ color: '#E74C3C', fontSize: '13px' }} className="flex items-center justify-between">
|
||
|
|
<span>{error}</span>
|
||
|
|
<button onClick={() => setError(null)} style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<X size={14} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div style={cardBase} className="p-3 space-y-3">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-1 text-sm flex-wrap" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<button onClick={() => setPath('.')} style={{ color: TEXT_PRIMARY }}>
|
||
|
|
root
|
||
|
|
</button>
|
||
|
|
{breadcrumbs.map((part, i) => (
|
||
|
|
<span key={i} className="flex items-center gap-1">
|
||
|
|
<ChevronRight size={12} />
|
||
|
|
<button onClick={() => setPath(breadcrumbs.slice(0, i + 1).join('/'))} style={{ color: TEXT_PRIMARY }}>
|
||
|
|
{part}
|
||
|
|
</button>
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<button onClick={refresh} className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs" style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}>
|
||
|
|
<RefreshCw size={12} /> Refresh
|
||
|
|
</button>
|
||
|
|
<button onClick={handleMkdir} className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs" style={{ border: '1px solid rgba(255,255,255,0.1)', color: TEXT_PRIMARY }}>
|
||
|
|
<FolderPlus size={12} /> New Folder
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
|
||
|
|
style={{ backgroundColor: GOLD, color: '#0A0A0C' }}
|
||
|
|
>
|
||
|
|
<Upload size={12} /> Upload
|
||
|
|
</button>
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
className="hidden"
|
||
|
|
onChange={(e) => {
|
||
|
|
const file = e.target.files?.[0]
|
||
|
|
if (file) handleUpload(file)
|
||
|
|
e.target.value = ''
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<table className="w-full text-sm">
|
||
|
|
<tbody>
|
||
|
|
{path !== '.' && path !== '' && (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={4} className="py-1.5 cursor-pointer" style={{ color: TEXT_SECONDARY }} onClick={goUp}>
|
||
|
|
../
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
{entries.map((entry) => (
|
||
|
|
<tr key={entry.name} className="hover:bg-white/[0.02]">
|
||
|
|
<td
|
||
|
|
className="py-1.5 cursor-pointer flex items-center gap-2"
|
||
|
|
style={{ color: TEXT_PRIMARY }}
|
||
|
|
onClick={() => (entry.isDirectory ? openDirectory(entry.name) : openFile(entry.name))}
|
||
|
|
>
|
||
|
|
{entry.isDirectory ? <Folder size={14} color={GOLD} /> : <FileIcon size={14} color={TEXT_SECONDARY} />}
|
||
|
|
{entry.name}
|
||
|
|
</td>
|
||
|
|
<td className="py-1.5 text-right" style={{ color: TEXT_SECONDARY, width: '90px' }}>
|
||
|
|
{entry.isDirectory ? '' : formatSize(entry.size)}
|
||
|
|
</td>
|
||
|
|
<td className="py-1.5 text-right" style={{ color: TEXT_SECONDARY, width: '70px' }}>
|
||
|
|
{(entry.mode & 0o777).toString(8)}
|
||
|
|
</td>
|
||
|
|
<td className="py-1.5 text-right" style={{ width: '120px' }}>
|
||
|
|
<div className="flex items-center justify-end gap-2">
|
||
|
|
{!entry.isDirectory && (
|
||
|
|
<button onClick={() => handleDownload(entry)} title="Download" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<Download size={14} />
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<button onClick={() => handleRename(entry)} title="Rename" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<Pencil size={14} />
|
||
|
|
</button>
|
||
|
|
<button onClick={() => handleDelete(entry)} title="Delete" style={{ color: '#E74C3C' }}>
|
||
|
|
<Trash2 size={14} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
{entries.length === 0 && !loading && (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={4} className="py-4 text-center" style={{ color: TEXT_SECONDARY }}>
|
||
|
|
Empty directory
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{editingPath && (
|
||
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}>
|
||
|
|
<div style={cardBase} className="p-4 w-3/4 max-w-3xl h-3/4 flex flex-col gap-3">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<span className="text-sm font-medium" style={{ color: TEXT_PRIMARY }}>
|
||
|
|
{editingPath}
|
||
|
|
</span>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<button
|
||
|
|
onClick={saveEdit}
|
||
|
|
disabled={savingEdit || editingEncoding === 'base64'}
|
||
|
|
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-xs"
|
||
|
|
style={{ backgroundColor: GOLD, color: '#0A0A0C', opacity: editingEncoding === 'base64' ? 0.5 : 1 }}
|
||
|
|
>
|
||
|
|
<Save size={12} /> Save
|
||
|
|
</button>
|
||
|
|
<button onClick={() => setEditingPath(null)} style={{ color: TEXT_SECONDARY }}>
|
||
|
|
<X size={16} />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{editingEncoding === 'base64' ? (
|
||
|
|
<div style={{ color: TEXT_SECONDARY, fontSize: '13px' }}>Binary file - editing not supported. Use download instead.</div>
|
||
|
|
) : (
|
||
|
|
<textarea
|
||
|
|
value={editingContent}
|
||
|
|
onChange={(e) => setEditingContent(e.target.value)}
|
||
|
|
className="flex-1 resize-none"
|
||
|
|
style={{
|
||
|
|
...inputStyle(),
|
||
|
|
fontFamily: 'monospace',
|
||
|
|
width: '100%',
|
||
|
|
height: '100%',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|