feat: implement file transfer functionality with multi-select, sorting, and directory traversal support
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+5
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user