dev_arc_aws/src/pages/Files.tsx

361 lines
12 KiB
TypeScript
Raw Normal View History

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>
)
}