feat: implement file transfer functionality with multi-select, sorting, and directory traversal support

This commit is contained in:
2026-06-09 11:41:49 +01:00
parent 13848fb227
commit c0ab7acdf2
11 changed files with 466 additions and 35 deletions
+66
View File
@@ -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)
}
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+43 -11
View File
@@ -22,7 +22,10 @@
<div id="main-area">
<div id="toolbar">
<div id="breadcrumb">/</div>
<div style="display: flex; align-items: center; flex: 1;">
<button class="btn" style="margin-right: 0.5rem; padding: 0.25rem 0.75rem;" onclick="navigateUp()"></button>
<div id="breadcrumb">/</div>
</div>
<div class="action-bar">
<div style="position: relative; display: inline-block;">
<button class="btn" onclick="toggleUploadMenu(event)">Upload ▾</button>
@@ -31,18 +34,18 @@
<div class="menu-item" onclick="promptUploadDirectory(); toggleUploadMenu()">Folder</div>
</div>
</div>
<button class="btn" onclick="refreshFiles()">Refresh</button>
<button class="btn" id="transfers-btn" onclick="toggleTransfers()">Transfers (0)</button>
<button class="btn" onclick="refreshCurrentDir()">Refresh</button>
<button class="btn" id="transfers-btn" onclick="showTransfers()">Transfers (0)</button>
</div>
</div>
<div id="browser-pane">
<table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Modified</th>
<th>Permissions</th>
<th onclick="setSort('name')" style="cursor:pointer">Name <span id="sort-ind-name"></span></th>
<th onclick="setSort('size')" style="cursor:pointer">Size <span id="sort-ind-size"></span></th>
<th onclick="setSort('modified')" style="cursor:pointer">Modified <span id="sort-ind-modified"></span></th>
<th onclick="setSort('permissions')" style="cursor:pointer">Permissions <span id="sort-ind-permissions"></span></th>
</tr>
</thead>
<tbody id="file-list">
@@ -53,7 +56,10 @@
<div id="transfer-tray">
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
<h3 style="margin: 0;">Transfers</h3>
<button class="btn" onclick="hideTransfers()">Close</button>
<div>
<button class="btn" style="margin-right: 0.5rem;" onclick="clearTransfers()">Clear Queue</button>
<button class="btn" onclick="hideTransfers()">Close</button>
</div>
</div>
<div id="transfer-list">
</div>
@@ -132,9 +138,35 @@
<!-- Context Menu -->
<div id="context-menu" class="context-menu" style="display: none;">
<div class="menu-item" onclick="handleContextMenu('rename')">Rename</div>
<div class="menu-item" onclick="handleContextMenu('download')">Download</div>
<div class="menu-item" style="color: var(--error);" onclick="handleContextMenu('delete')">Delete</div>
<div id="ctx-rename" class="menu-item" onclick="handleContextMenu('rename')">Rename</div>
<div id="ctx-transfer" class="menu-item" onclick="handleContextMenu('transfer')">Transfer</div>
<div id="ctx-download" class="menu-item" onclick="handleContextMenu('download')">Download</div>
<div id="ctx-delete" class="menu-item" style="color: var(--error);" onclick="handleContextMenu('delete')">Delete</div>
<div id="ctx-divider" style="border-top: 1px solid var(--border); margin: 0.25rem 0;"></div>
<div class="menu-item" onclick="handleContextMenu('toggle_hidden')"><span id="ctx-hidden-text">Show Hidden Files</span></div>
</div>
<!-- Transfer Modal -->
<div id="transfer-modal" class="modal-overlay">
<div class="modal-content">
<h2 style="margin-top: 0;">Select Destination</h2>
<div class="form-group">
<label>Connection</label>
<select id="transfer-dest-conn" onchange="loadTransferDestRoot()"></select>
</div>
<div class="form-group" style="margin-bottom: 0.5rem;">
<div id="transfer-dest-breadcrumb" style="font-size: 0.8rem; color: var(--text-secondary); word-break: break-all;">/</div>
</div>
<div id="transfer-dest-browser" style="height: 300px; overflow-y: auto; border: 1px solid var(--border); background: var(--bg); margin-bottom: 1rem;">
<table style="width: 100%;">
<tbody id="transfer-dest-list"></tbody>
</table>
</div>
<div style="display: flex; justify-content: flex-end; gap: 1rem;">
<button class="btn" style="background: transparent; color: var(--text-primary); border-color: var(--border);" onclick="closeTransferModal()">Cancel</button>
<button class="btn btn-primary" onclick="executeTransfer()">Transfer Here</button>
</div>
</div>
</div>
<!-- Modals and scripts -->
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+307 -24
View File
@@ -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 = '<tr><td colspan="4">Empty directory</td></tr>';
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 = `
<td>${f.is_dir ? '📁 ' : '📄 '}${f.name}</td>
<td>${f.is_dir ? '-' : formatBytes(f.size)}</td>
<td>${f.modified}</td>
<td>${formatDate(f.modified)}</td>
<td>${f.permissions}</td>
`;
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 = '<div style="color: var(--text-secondary);">No active transfers</div>';
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 += `
<div class="transfer-item">
<div style="width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 0.85rem;" title="${t.filename}">${t.filename}</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${pct}%; background: ${color}"></div>
</div>
<div class="transfer-meta">
<div>${formatBytes(t.bytes_done)} / ${formatBytes(t.bytes_total)}</div>
<div style="color: ${color}">${speed} ${eta}</div>
${t.error ? `<div style="color: var(--error); font-size: 0.7rem;">${t.error}</div>` : ''}
</div>
</div>`;
});
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 = `<option value="local">Local Filesystem</option>`;
connectionsCache.forEach(c => {
select.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
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 = '<tr><td colspan="4">Loading...</td></tr>';
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 = `<td>📁 ..</td>`;
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 += '<tr><td>Empty directory</td></tr>';
return;
}
files.forEach(f => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${f.is_dir ? '📁 ' : '📄 '}${f.name}</td>`;
if (f.is_dir) {
tr.ondblclick = () => {
loadTransferDestDirectory(connId, f.path);
};
}
tbody.appendChild(tr);
});
} catch(e) {
tbody.innerHTML = `<tr><td style="color: var(--error)">Error: ${e}</td></tr>`;
}
};
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);
+4
View File
@@ -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;
+5
View File
@@ -3,6 +3,9 @@
import {config} from '../models';
import {transfer} from '../models';
import {explorer} from '../models';
import {main} from '../models';
export function ClearTransfers():Promise<void>;
export function Delete(arg1:string,arg2:string):Promise<void>;
@@ -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<void>;
export function SaveConnection(arg1:config.ConnectionConfig,arg2:string):Promise<void>;
export function TransferItems(arg1:string,arg2:string,arg3:string,arg4:Array<main.TransferItem>):Promise<void>;
+8
View File
@@ -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);
}
+23
View File
@@ -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 {
+10
View File
@@ -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)
}
}
}