diff --git a/app.go b/app.go index 0581bb2..764e901 100644 --- a/app.go +++ b/app.go @@ -185,6 +185,10 @@ func (a *App) GetTransfers() []*transfer.Transfer { return a.transferManager.GetTransfers() } +func (a *App) ClearTransfers() { + a.transferManager.ClearCompleted() +} + func (a *App) Rename(connId, src, dst string) error { exp, err := a.getExplorerForConnection(connId) if err != nil { @@ -295,3 +299,65 @@ func (a *App) PromptDownload(connId, remotePath string) error { return a.QueueTransfer(id, connId, "local", remotePath, localPath, fileName, size) } + +type TransferItem struct { + Path string `json:"path"` + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` +} + +func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []TransferItem) error { + for _, item := range items { + if !item.IsDir { + id := uuid.New().String() + + remotePath := dstPath + if dstPath == "" || dstPath == "/" { + remotePath = item.Name + } else if dstPath[len(dstPath)-1] != '/' { + remotePath = dstPath + "/" + item.Name + } else { + remotePath = dstPath + item.Name + } + + if err := a.QueueTransfer(id, srcConnId, dstConnId, item.Path, remotePath, item.Name, item.Size); err != nil { + return err + } + } else { + // Run remote walk in goroutine to not block UI + go a.transferRemoteDirectory(srcConnId, dstConnId, item.Path, dstPath) + } + } + return nil +} + +func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBasePath string) { + baseName := filepath.Base(srcDirPath) + + newDstPath := dstBasePath + if dstBasePath == "" || dstBasePath == "/" { + newDstPath = baseName + } else if dstBasePath[len(dstBasePath)-1] != '/' { + newDstPath = dstBasePath + "/" + baseName + } else { + newDstPath = dstBasePath + baseName + } + + a.MkDir(dstConnId, newDstPath) + + entries, err := a.ListDir(srcConnId, srcDirPath) + if err != nil { + return + } + + for _, entry := range entries { + if entry.IsDir { + a.transferRemoteDirectory(srcConnId, dstConnId, entry.Path, newDstPath) + } else { + id := uuid.New().String() + itemDstPath := newDstPath + "/" + entry.Name + a.QueueTransfer(id, srcConnId, dstConnId, entry.Path, itemDstPath, entry.Name, entry.Size) + } + } +} diff --git a/build/appicon.png b/build/appicon.png index 63617fe..91c5e19 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico index f334798..160ceda 100644 Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ diff --git a/frontend/index.html b/frontend/index.html index 73cb12b..77c94c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,7 +22,10 @@
- +
+ + +
@@ -31,18 +34,18 @@
- - + +
- - - - + + + + @@ -53,7 +56,10 @@

Transfers

- +
+ + +
@@ -132,9 +138,35 @@ + + +
NameSizeModifiedPermissionsName Size Modified Permissions
+ +
+
+
+ + +
+ diff --git a/frontend/src/assets/appicon.png b/frontend/src/assets/appicon.png index 63617fe..91c5e19 100644 Binary files a/frontend/src/assets/appicon.png and b/frontend/src/assets/appicon.png differ diff --git a/frontend/src/main.js b/frontend/src/main.js index 40bd829..2ef8e65 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,8 +1,13 @@ -import { ListDir, GetConnections, SaveConnection, DeleteConnection, Delete, Rename, PromptUploadFiles, PromptUploadDirectory, PromptDownload } from '../wailsjs/go/main/App.js'; +import { ListDir, GetConnections, SaveConnection, DeleteConnection, Delete, Rename, PromptUploadFiles, PromptUploadDirectory, PromptDownload, TransferItems, GetTransfers, ClearTransfers } from '../wailsjs/go/main/App.js'; let currentConn = 'local'; let currentPath = ''; let connectionsCache = []; +let selectedItems = []; +let lastSelectedPath = null; +let showHiddenFiles = false; +let sortField = 'name'; +let sortAsc = true; function uuidv4() { return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c => @@ -138,27 +143,104 @@ async function loadDirectory(connId, path) { try { const files = await ListDir(connId, path); tbody.innerHTML = ''; - if (!files || files.length === 0) { + selectedItems = []; + lastSelectedPath = null; + + let displayFiles = files || []; + if (!showHiddenFiles) { + displayFiles = displayFiles.filter(f => !f.name.startsWith('.')); + } + + if (displayFiles.length === 0) { tbody.innerHTML = 'Empty directory'; return; } - files.forEach(f => { + displayFiles.sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + + let cmp = 0; + switch(sortField) { + case 'name': + cmp = a.name.localeCompare(b.name); + break; + case 'size': + cmp = a.size - b.size; + break; + case 'modified': + cmp = new Date(a.modified).getTime() - new Date(b.modified).getTime(); + if (isNaN(cmp)) cmp = 0; + break; + case 'permissions': + cmp = a.permissions.localeCompare(b.permissions); + break; + } + return sortAsc ? cmp : -cmp; + }); + + displayFiles.forEach(f => { const tr = document.createElement('tr'); + tr.dataset.path = f.path; + tr.dataset.name = f.name; + tr.dataset.isDir = f.is_dir; + tr.dataset.size = f.size; + tr.innerHTML = ` ${f.is_dir ? '📁 ' : '📄 '}${f.name} ${f.is_dir ? '-' : formatBytes(f.size)} - ${f.modified} + ${formatDate(f.modified)} ${f.permissions} `; + + tr.onclick = (e) => { + const path = f.path; + const isSelected = selectedItems.some(i => i.path === path); + + if (e.ctrlKey || e.metaKey) { + if (isSelected) { + selectedItems = selectedItems.filter(i => i.path !== path); + tr.classList.remove('selected'); + } else { + selectedItems.push(f); + tr.classList.add('selected'); + } + lastSelectedPath = path; + } else if (e.shiftKey && lastSelectedPath) { + const allRows = Array.from(tbody.querySelectorAll('tr')); + const lastIdx = allRows.findIndex(r => r.dataset.path === lastSelectedPath); + const currIdx = allRows.findIndex(r => r.dataset.path === path); + const startIdx = Math.min(lastIdx, currIdx); + const endIdx = Math.max(lastIdx, currIdx); + + selectedItems = []; + allRows.forEach(r => r.classList.remove('selected')); + + for(let i=startIdx; i<=endIdx; i++) { + const row = allRows[i]; + if(row.dataset.path) { + row.classList.add('selected'); + selectedItems.push({ + path: row.dataset.path, + name: row.dataset.name, + is_dir: row.dataset.isDir === 'true', + size: parseInt(row.dataset.size) || 0 + }); + } + } + } else { + selectedItems = [f]; + Array.from(tbody.querySelectorAll('tr')).forEach(r => r.classList.remove('selected')); + tr.classList.add('selected'); + lastSelectedPath = path; + } + }; + if (f.is_dir) { tr.ondblclick = () => { currentPath = f.path; loadDirectory(currentConn, currentPath); }; } - tr.dataset.path = f.path; - tr.dataset.name = f.name; tbody.appendChild(tr); }); } catch(e) { @@ -185,22 +267,122 @@ function formatBytes(bytes) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } +function formatDate(dateString) { + if (!dateString) return '-'; + const d = new Date(dateString); + if (isNaN(d.getTime())) return dateString; + return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth()+1).padStart(2, '0')}/${d.getFullYear()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +window.setSort = (field) => { + if (sortField === field) { + sortAsc = !sortAsc; + } else { + sortField = field; + sortAsc = true; + } + ['name', 'size', 'modified', 'permissions'].forEach(f => { + document.getElementById(`sort-ind-${f}`).innerText = ''; + }); + document.getElementById(`sort-ind-${field}`).innerText = sortAsc ? '↑' : '↓'; + refreshCurrentDir(); +}; + +window.clearTransfers = async () => { + try { + await ClearTransfers(); + pollTransfers(); + } catch(e) { + console.error(e); + } +}; + +async function pollTransfers() { + try { + const transfers = await GetTransfers(); + const list = document.getElementById('transfer-list'); + const btn = document.getElementById('transfers-btn'); + btn.innerText = `Transfers (${transfers ? transfers.length : 0})`; + + if (!transfers || transfers.length === 0) { + list.innerHTML = '
No active transfers
'; + return; + } + + let html = ''; + transfers.forEach(t => { + const pct = t.bytes_total > 0 ? (t.bytes_done / t.bytes_total) * 100 : 0; + const speed = t.status === 'active' ? `${t.speed_mbps.toFixed(2)} MB/s` : t.status; + const eta = t.status === 'active' ? `${t.eta_seconds}s remaining` : ''; + const color = t.status === 'failed' ? 'var(--error)' : (t.status === 'complete' ? 'var(--success)' : 'var(--accent)'); + + html += ` +
+
${t.filename}
+
+
+
+
+
${formatBytes(t.bytes_done)} / ${formatBytes(t.bytes_total)}
+
${speed} ${eta}
+ ${t.error ? `
${t.error}
` : ''} +
+
`; + }); + list.innerHTML = html; + } catch(e) { + console.error(e); + } +} + +setInterval(pollTransfers, 1000); + window.addEventListener('load', init); // Context Menu Logic -let contextMenuTarget = null; - document.addEventListener('contextmenu', (e) => { const menu = document.getElementById('context-menu'); menu.style.display = 'none'; + document.getElementById('ctx-hidden-text').innerText = showHiddenFiles ? 'Hide Hidden Files' : 'Show Hidden Files'; + const row = e.target.closest('tr'); - if (row && row.dataset.path) { + const isBrowserPane = e.target.closest('#browser-pane'); + + if (row && row.dataset.path && !row.closest('#transfer-dest-list')) { e.preventDefault(); - contextMenuTarget = { - path: row.dataset.path, - name: row.dataset.name - }; + + if (!selectedItems.some(i => i.path === row.dataset.path)) { + selectedItems = [{ + path: row.dataset.path, + name: row.dataset.name, + is_dir: row.dataset.isDir === 'true', + size: parseInt(row.dataset.size) || 0 + }]; + const tbody = document.getElementById('file-list'); + Array.from(tbody.querySelectorAll('tr')).forEach(r => r.classList.remove('selected')); + row.classList.add('selected'); + lastSelectedPath = row.dataset.path; + } + + document.getElementById('ctx-rename').style.display = 'block'; + document.getElementById('ctx-transfer').style.display = 'block'; + document.getElementById('ctx-download').style.display = 'block'; + document.getElementById('ctx-delete').style.display = 'block'; + document.getElementById('ctx-divider').style.display = 'block'; + + menu.style.display = 'block'; + menu.style.left = `${e.pageX}px`; + menu.style.top = `${e.pageY}px`; + } else if (isBrowserPane && !e.target.closest('#transfer-dest-list')) { + e.preventDefault(); + + document.getElementById('ctx-rename').style.display = 'none'; + document.getElementById('ctx-transfer').style.display = 'none'; + document.getElementById('ctx-download').style.display = 'none'; + document.getElementById('ctx-delete').style.display = 'none'; + document.getElementById('ctx-divider').style.display = 'none'; + menu.style.display = 'block'; menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; @@ -224,36 +406,137 @@ window.toggleUploadMenu = (e) => { }; window.handleContextMenu = async (action) => { - if (!contextMenuTarget) return; - - const { path, name } = contextMenuTarget; + if (action === 'toggle_hidden') { + showHiddenFiles = !showHiddenFiles; + refreshCurrentDir(); + return; + } + + if (selectedItems.length === 0) return; try { if (action === 'delete') { - if (confirm(`Are you sure you want to delete ${name}?`)) { - await Delete(currentConn, path); + if (confirm(`Are you sure you want to delete ${selectedItems.length} items?`)) { + for (const item of selectedItems) { + await Delete(currentConn, item.path); + } refreshCurrentDir(); } } else if (action === 'rename') { - const newName = prompt(`Enter new name for ${name}:`, name); - if (newName && newName !== name) { - // Construct new path by replacing the last part - const pathParts = path.split('/'); + if (selectedItems.length > 1) { + alert("Cannot rename multiple items at once."); + return; + } + const item = selectedItems[0]; + const newName = prompt(`Enter new name for ${item.name}:`, item.name); + if (newName && newName !== item.name) { + const pathParts = item.path.split('/'); pathParts[pathParts.length - 1] = newName; const newPath = pathParts.join('/'); - await Rename(currentConn, path, newPath); + await Rename(currentConn, item.path, newPath); refreshCurrentDir(); } } else if (action === 'download') { - await PromptDownload(currentConn, path); + for (const item of selectedItems) { + await PromptDownload(currentConn, item.path); + } showTransfers(); + } else if (action === 'transfer') { + openTransferModal(); } } catch (e) { alert(`Failed to ${action}: ${e}`); } }; +window.navigateUp = () => { + if (!currentPath || currentPath === '/') return; + const parts = currentPath.split('/').filter(p => p); + parts.pop(); + currentPath = parts.length ? '/' + parts.join('/') : ''; + loadDirectory(currentConn, currentPath); +}; + +let transferDestConn = 'local'; +let transferDestPath = ''; + +window.openTransferModal = () => { + const select = document.getElementById('transfer-dest-conn'); + select.innerHTML = ``; + connectionsCache.forEach(c => { + select.innerHTML += ``; + }); + document.getElementById('transfer-modal').style.display = 'flex'; + loadTransferDestRoot(); +}; + +window.closeTransferModal = () => { + document.getElementById('transfer-modal').style.display = 'none'; +}; + +window.loadTransferDestRoot = () => { + transferDestConn = document.getElementById('transfer-dest-conn').value; + transferDestPath = ''; + loadTransferDestDirectory(transferDestConn, transferDestPath); +}; + +window.loadTransferDestDirectory = async (connId, path) => { + transferDestPath = path; + const tbody = document.getElementById('transfer-dest-list'); + tbody.innerHTML = 'Loading...'; + document.getElementById('transfer-dest-breadcrumb').innerText = path || '/'; + + try { + const files = await ListDir(connId, path); + tbody.innerHTML = ''; + + if (path && path !== '/') { + const tr = document.createElement('tr'); + tr.innerHTML = `📁 ..`; + tr.style.cursor = 'pointer'; + tr.ondblclick = () => { + const parts = path.split('/').filter(p => p); + parts.pop(); + loadTransferDestDirectory(connId, parts.length ? '/' + parts.join('/') : ''); + }; + tbody.appendChild(tr); + } + + if (!files || files.length === 0) { + tbody.innerHTML += 'Empty directory'; + return; + } + + files.forEach(f => { + const tr = document.createElement('tr'); + tr.style.cursor = 'pointer'; + tr.innerHTML = `${f.is_dir ? '📁 ' : '📄 '}${f.name}`; + if (f.is_dir) { + tr.ondblclick = () => { + loadTransferDestDirectory(connId, f.path); + }; + } + tbody.appendChild(tr); + }); + } catch(e) { + tbody.innerHTML = `Error: ${e}`; + } +}; + +window.executeTransfer = async () => { + if (selectedItems.length === 0) return; + try { + await TransferItems(currentConn, transferDestConn, transferDestPath, selectedItems); + closeTransferModal(); + showTransfers(); + selectedItems = []; + Array.from(document.querySelectorAll('#file-list tr')).forEach(r => r.classList.remove('selected')); + } catch(e) { + alert("Transfer failed: " + e); + } +}; + window.promptUploadFiles = async () => { try { await PromptUploadFiles(currentConn, currentPath); diff --git a/frontend/src/style.css b/frontend/src/style.css index 55cd12b..1472b6d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -142,6 +142,10 @@ tbody tr:hover { background: var(--surface-hover); } +tbody tr.selected { + background: rgba(33, 209, 152, 0.2); +} + .btn { background: var(--accent); color: #000; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 6b90e6f..90d039c 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -3,6 +3,9 @@ import {config} from '../models'; import {transfer} from '../models'; import {explorer} from '../models'; +import {main} from '../models'; + +export function ClearTransfers():Promise; export function Delete(arg1:string,arg2:string):Promise; @@ -27,3 +30,5 @@ export function QueueTransfer(arg1:string,arg2:string,arg3:string,arg4:string,ar export function Rename(arg1:string,arg2:string,arg3:string):Promise; export function SaveConnection(arg1:config.ConnectionConfig,arg2:string):Promise; + +export function TransferItems(arg1:string,arg2:string,arg3:string,arg4:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 64acde2..c0c70e3 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function ClearTransfers() { + return window['go']['main']['App']['ClearTransfers'](); +} + export function Delete(arg1, arg2) { return window['go']['main']['App']['Delete'](arg1, arg2); } @@ -49,3 +53,7 @@ export function Rename(arg1, arg2, arg3) { export function SaveConnection(arg1, arg2) { return window['go']['main']['App']['SaveConnection'](arg1, arg2); } + +export function TransferItems(arg1, arg2, arg3, arg4) { + return window['go']['main']['App']['TransferItems'](arg1, arg2, arg3, arg4); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index cadcfc3..c32dbbd 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -60,6 +60,29 @@ export namespace explorer { } +export namespace main { + + export class TransferItem { + path: string; + name: string; + is_dir: boolean; + size: number; + + static createFrom(source: any = {}) { + return new TransferItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.path = source["path"]; + this.name = source["name"]; + this.is_dir = source["is_dir"]; + this.size = source["size"]; + } + } + +} + export namespace transfer { export class Transfer { diff --git a/internal/transfer/transfer.go b/internal/transfer/transfer.go index 7d852aa..4b9b6d8 100644 --- a/internal/transfer/transfer.go +++ b/internal/transfer/transfer.go @@ -142,3 +142,13 @@ func (m *Manager) GetTransfers() []*Transfer { } return res } + +func (m *Manager) ClearCompleted() { + m.mu.Lock() + defer m.mu.Unlock() + for id, t := range m.transfers { + if t.Status == StatusComplete || t.Status == StatusFailed { + delete(m.transfers, id) + } + } +}