From 6c72489de36b2792ca5ca7575e483beba93519d8 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Fri, 4 Sep 2026 02:22:59 +0800 Subject: [PATCH] Improve Files navigation and transfers ## Why The Files window provides cross-endpoint transfers, but its source and destination states are visually dense and file selection can look like an immediate download. Local browser uploads also cannot reuse the destination workflow. ## What changed - Add sortable file metadata, directory-first ordering, prefix navigation, refresh controls, and detailed selection information. - Switch Copy to a focused destination pane and keep cancellation available before publish. - Allow browser-selected files to target compatible SSH or local endpoints while preserving explicit Download and endpoint capability checks. - Reset the window to its source pane on reopen and retain the existing typed backend transfer path. ## Testing Covers the complete headless smoke suite and Chromium Files workflows, including sorting, selection, destination navigation, local uploads, cross-endpoint copies, and cancellation. --- templates/index.html | 567 +++++++++++++++++++++++-------- terminal_backends/local_shell.py | 6 +- terminal_backends/ssh.py | 5 +- tests/agent_backend_smoke.py | 6 +- tests/agent_browser_smoke.py | 230 +++++++++++-- 5 files changed, 637 insertions(+), 177 deletions(-) diff --git a/templates/index.html b/templates/index.html index 61b1bab..8850e08 100644 --- a/templates/index.html +++ b/templates/index.html @@ -121,32 +121,22 @@ } .sftp-pip-header { display: flex; align-items: center; gap: 8px; } .sftp-pip-title { flex: 1; color: #fff; font-size: 14px; font-weight: 700; } - .sftp-pip-close { - width: 26px; height: 26px; padding: 0; border: 1px solid #444; border-radius: 5px; - background: #252527; color: #ddd; cursor: pointer; font-size: 17px; line-height: 1; - } - .sftp-pip-close:hover { border-color: #ff453a; color: #fff; } .sftp-files-workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; overflow: hidden; } - .sftp-files-workspace.copy-open { grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); } + .sftp-files-workspace.copy-open { grid-template-columns: minmax(0, 1fr); } + .sftp-files-workspace.copy-open .sftp-source-pane { display: none; } .sftp-source-pane, .sftp-destination-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow: hidden; } .sftp-destination-pane { - padding-left: 10px; border-left: 1px solid #3a3a3c; animation: sftp-destination-slide 0.16s ease-out; } @keyframes sftp-destination-slide { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } } - @media (max-width: 760px) { - .sftp-files-workspace.copy-open { grid-template-columns: minmax(0, 1fr); } - .sftp-files-workspace.copy-open .sftp-source-pane { display: none; } - .sftp-destination-pane { border-left: 0; padding-left: 0; } - } .sftp-destination-header { display: flex; align-items: center; gap: 8px; } .sftp-destination-title { flex: 1; color: #fff; font-size: 13px; font-weight: 700; } .sftp-session-select, .sftp-copy-name-input { @@ -155,17 +145,24 @@ } .sftp-session-select { width: 100%; } .sftp-copy-name-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: center; } - .sftp-copy-summary { + .sftp-source-file-card { padding: 8px 10px; border: 1px solid #3a3a3c; border-radius: 6px; - background: #202022; color: #ddd; overflow-wrap: anywhere; + background: #202022; color: #ddd; + } + .sftp-source-file-label { margin-bottom: 4px; color: #aaa; font-size: 11px; } + .sftp-file-detail-path { + min-width: 0; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; } + .sftp-file-detail-meta { margin-top: 3px; color: #aaa; text-align: right; white-space: nowrap; } .sftp-copy-lifecycle-hint { color: #aaa; font-size: 11px; line-height: 1.4; } .sftp-endpoint-card { padding: 8px 10px; border: 1px solid #3a3a3c; border-radius: 6px; background: #202022; } .sftp-endpoint-value { color: #fff; font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } .sftp-direct-hint { margin-top: 3px; color: #ffcc00; font-size: 11px; } - .sftp-path-controls { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; gap: 6px; } + .sftp-path-controls { display: grid; grid-template-columns: auto auto auto minmax(0, 1fr) auto; gap: 6px; } + .sftp-path-controls .sftp-icon-button { width: 32px; padding: 7px 0; font-size: 15px; } .sftp-path-controls button, .sftp-pip-actions button, .sftp-conflict-actions button, .sftp-file-operation-actions button, .sftp-rename-controls button, .sftp-delete-actions button { margin: 0; padding: 7px 10px; border: 1px solid #444; border-radius: 5px; @@ -190,16 +187,27 @@ flex: 1; min-height: 90px; overflow: auto; border: 1px solid #343437; border-radius: 6px; background: #101011; } + .sftp-list-header, .sftp-directory-entry { + display: grid; grid-template-columns: minmax(0, 1fr) 92px 138px; align-items: center; gap: 10px; + } + .sftp-list-header { + position: sticky; top: 0; z-index: 1; border-bottom: 1px solid #444; background: #252527; + } + .sftp-sort-button { + min-width: 0; margin: 0; padding: 6px 10px; border: 0; background: transparent; color: #aaa; + cursor: pointer; text-align: left; font-size: 11px; white-space: nowrap; + } + .sftp-sort-button:hover, .sftp-sort-button.active { color: #fff; background: #303033; } + .sftp-sort-button:focus-visible { outline: 2px solid #72b5e5; outline-offset: -2px; } .sftp-directory-entry { - display: block; width: 100%; box-sizing: border-box; margin: 0; padding: 7px 10px; + width: 100%; box-sizing: border-box; margin: 0; padding: 7px 10px; border: 0; border-bottom: 1px solid #242426; background: transparent; color: #ddd; cursor: pointer; text-align: left; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sftp-directory-entry:hover { background: #183452; color: #fff; } .sftp-directory-entry.selected { background: #0a4f86; color: #fff; box-shadow: inset 3px 0 #72b5e5; } - .sftp-file-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; } .sftp-file-entry-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .sftp-file-entry-size { color: #888; white-space: nowrap; } + .sftp-file-entry-size, .sftp-file-entry-date { color: #888; white-space: nowrap; } .sftp-directory-list.busy .sftp-directory-entry { pointer-events: none; opacity: 0.45; } .sftp-directory-empty { padding: 12px; color: #777; text-align: center; } .sftp-file-operation-box { @@ -207,11 +215,9 @@ background: #202022; color: #ddd; } .sftp-file-operation-box.visible { display: block; } - .sftp-file-operation-path { - margin: 4px 0 8px; color: #fff; overflow-wrap: anywhere; - font: 12px/1.35 ui-monospace, SFMono-Regular, Consolas, monospace; - } + .sftp-file-operation-path { margin-bottom: 3px; } .sftp-file-operation-actions { display: flex; justify-content: flex-end; gap: 7px; } + .sftp-file-detail-meta + .sftp-file-operation-actions { margin-top: 8px; } .sftp-file-operation-actions .danger, .sftp-delete-actions .danger { border-color: #8f2d2d; color: #ff8a83; } .sftp-rename-controls { display: none; grid-template-columns: minmax(0, 1fr) auto auto; gap: 7px; } .sftp-rename-controls.visible { display: grid; } @@ -236,7 +242,10 @@ } .sftp-drop-zone:hover, .sftp-drop-zone:focus-visible { border-color: #72b5e5; background: #1b496f; outline: none; } .sftp-drop-zone.dragging { border-color: #8dceff; background: #205a86; color: #fff; } - .sftp-selected-file { min-height: 17px; color: #bbb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .sftp-selected-file { + display: none; padding: 9px; border: 1px solid #3a3a3c; border-radius: 6px; background: #202022; + } + .sftp-selected-file.visible { display: block; } .sftp-transfer-status { min-height: 18px; color: #aaa; overflow-wrap: anywhere; } .sftp-transfer-status.error { color: #ff6b61; } .sftp-transfer-status.success { color: #34c759; } @@ -244,7 +253,9 @@ .sftp-progress.visible { display: block; } .sftp-progress-bar { width: 0; height: 100%; background: #0a84ff; transition: width 0.1s linear; } .sftp-pip-actions, .sftp-conflict-actions { display: flex; justify-content: flex-end; gap: 7px; } - .sftp-pip-actions .primary { border-color: #0a84ff; background: #0a84ff; color: #fff; font-weight: 700; } + .sftp-pip-actions .primary, .sftp-file-operation-actions .primary { + border-color: #0a84ff; background: #0a84ff; color: #fff; font-weight: 700; + } .sftp-conflict-box { display: none; padding: 9px; border: 1px solid #7a5b12; border-radius: 6px; background: #2b2515; color: #ffdd83; } .sftp-conflict-box.visible { display: block; } .sftp-conflict-actions { margin-top: 8px; } @@ -5791,6 +5802,10 @@

Access token required

handleSftpDownloadTicketResult(cloneForTest(payload)); return true; }, + handleSftpUploadTicketResultForTest(payload) { + handleSftpUploadTicketResult(cloneForTest(payload)); + return true; + }, handleFilesCopyResultForTest(payload) { handleFilesCopyResult(cloneForTest(payload)); return true; @@ -7218,7 +7233,10 @@

Access token required

function formatSftpEndpoint(endpoint) { if (!endpoint || typeof endpoint !== 'object') return 'Direct endpoint'; if (endpoint.route === 'local') { - const shell = typeof endpoint.shell === 'string' && endpoint.shell ? ` (${endpoint.shell})` : ''; + const command = typeof endpoint.command === 'string' && endpoint.command + ? endpoint.command + : endpoint.shell; + const shell = typeof command === 'string' && command ? ` (${command})` : ''; return `Local Shell${shell}`; } const user = typeof endpoint.user === 'string' ? endpoint.user : ''; @@ -7240,6 +7258,30 @@

Access token required

return `${size.toFixed(size >= 10 ? 1 : 2)} ${unit}`; } + function formatSftpDetailedBytes(value) { + const bytes = Math.max(0, Math.trunc(Number(value) || 0)); + return `${formatSftpBytes(bytes)} (${bytes} bytes)`; + } + + function formatSftpModified(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds < 0) return ''; + const date = new Date(seconds * 1000); + if (Number.isNaN(date.getTime())) return ''; + const parts = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date).reduce((result, part) => { + result[part.type] = part.value; + return result; + }, {}); + return `${parts.hour}:${parts.minute} ${parts.month} ${parts.day} ${parts.year}`; + } + function nextSftpRequestId(prefix) { if (window.crypto && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`; return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -7282,38 +7324,46 @@

Access token required

state.busy = !!busy; if (state.mode === 'destination') { const copying = !!state.copying; + const uploading = !!state.uploading; + const transferring = copying || uploading; const copyPhase = state.copyPhase || 'idle'; - state.elements.home.disabled = busy || copying; - state.elements.up.disabled = busy || copying || !state.currentPath; - state.elements.go.disabled = busy || copying; - state.elements.path.disabled = busy || copying; - state.elements.session.disabled = busy || copying; - state.elements.filename.disabled = busy || copying; - state.elements.copy.disabled = busy || copying || !state.currentPath || !state.elements.filename.value; - state.elements.cancel.textContent = state.cancelPending + state.elements.home.disabled = busy || transferring; + state.elements.up.disabled = busy || transferring || !state.currentPath; + state.elements.refresh.disabled = busy || transferring || !state.currentPath; + state.elements.go.disabled = busy || transferring; + state.elements.path.disabled = busy || transferring; + state.elements.session.disabled = busy || transferring; + state.elements.filename.disabled = busy || transferring; + state.elements.copy.disabled = busy || transferring || !state.currentPath || !state.elements.filename.value; + state.elements.cancel.textContent = uploading + ? 'Cancel upload' + : (state.cancelPending ? 'Cancelling…' : (copyPhase === 'committing' ? 'Publishing…' - : (copying ? 'Cancel copy' : (state.finished ? 'Close' : 'Cancel'))); - state.elements.cancel.disabled = copying + : (copying ? 'Cancel copy' : (state.finished ? 'Close' : 'Cancel')))); + state.elements.cancel.disabled = uploading + ? false + : (copying ? copyPhase !== 'running' || state.cancelPending || !state.copyId || !socket || !socket.connected - : (busy && !state.finished); - state.elements.conflictCancel.disabled = busy || copying; - state.elements.conflictKeep.disabled = busy || copying; - state.elements.conflictReplace.disabled = busy || copying; - state.elements.directoryList.classList.toggle('busy', busy || copying); + : (busy && !state.finished)); + state.elements.conflictCancel.disabled = busy || transferring; + state.elements.conflictKeep.disabled = busy || transferring; + state.elements.conflictReplace.disabled = busy || transferring; + state.elements.directoryList.classList.toggle('busy', busy || transferring); state.elements.directoryList.querySelectorAll('button').forEach(button => { - button.disabled = busy || copying || button.dataset.fileEntry === 'true'; + button.disabled = busy || transferring || button.dataset.fileEntry === 'true'; }); return; } const uploading = !!state.uploading; state.elements.home.disabled = busy || uploading; state.elements.up.disabled = busy || uploading || !state.currentPath; + state.elements.refresh.disabled = busy || uploading || !state.currentPath; state.elements.go.disabled = busy || uploading; state.elements.path.disabled = busy || uploading; state.elements.send.disabled = busy || uploading || !state.file || !state.currentPath; - state.elements.close.disabled = busy || uploading; + state.elements.localCopy.disabled = busy || uploading || !state.file; state.elements.conflictCancel.disabled = busy || uploading; state.elements.conflictKeep.disabled = busy || uploading; state.elements.conflictReplace.disabled = busy || uploading; @@ -7334,9 +7384,12 @@

Access token required

function updateSftpSelectedFile(state) { if (!state || !state.elements) return; - state.elements.selectedFile.textContent = state.file - ? `${state.file.name} · ${formatSftpBytes(state.file.size)}` - : 'No file selected.'; + const selected = !!state.file; + state.elements.selectedFile.classList.toggle('visible', selected); + state.elements.selectedFilePath.textContent = selected ? state.file.name : ''; + state.elements.selectedFileMeta.textContent = selected + ? `${formatSftpDetailedBytes(state.file.size)} ${formatSftpModified(state.file.lastModified / 1000)}` + : ''; setSftpPipBusy(state, state.busy); } @@ -7369,7 +7422,11 @@

Access token required

function closeSftpFileOperation(state, restoreFocus = true) { if (!state || !state.elements) return; - if (state.copyDestinationState && !state.copyDestinationState.copying) { + if ( + state.copyDestinationState + && !state.copyDestinationState.copying + && !state.copyDestinationState.uploading + ) { closeSftpCopyDestination(state, false); } state.elements.fileOperation.classList.remove('visible'); @@ -7396,7 +7453,19 @@

Access token required

function showSftpFileOperation(state, file, sourceButton) { if (!state || !state.elements || state.busy || state.uploading) return; - if (state.copyDestinationState && !state.copyDestinationState.copying) { + if ( + state.selectedRemoteFile + && state.selectedRemoteFile.fileId === file.fileId + && state.selectedRemoteFileButton === sourceButton + ) { + closeSftpFileOperation(state); + return; + } + if ( + state.copyDestinationState + && !state.copyDestinationState.copying + && !state.copyDestinationState.uploading + ) { closeSftpCopyDestination(state, false); } if (state.selectedRemoteFileButton && state.selectedRemoteFileButton !== sourceButton) { @@ -7408,6 +7477,7 @@

Access token required

sourceButton.classList.add('selected'); sourceButton.setAttribute('aria-pressed', 'true'); state.elements.fileOperationPath.textContent = getSftpRemoteFilePath(state, file); + state.elements.fileOperationMeta.textContent = `${formatSftpDetailedBytes(file.size)} ${formatSftpModified(file.mtime)}`; state.elements.fileOperation.classList.add('visible'); state.elements.fileOperationActions.hidden = false; state.elements.renameControls.classList.remove('visible'); @@ -7415,7 +7485,6 @@

Access token required

state.preparedDownload = null; state.downloadConsumed = false; prepareSftpDownloadTicket(state, file); - state.elements.fileRename.focus(); } function getSftpRenameValidation(state) { @@ -7476,67 +7545,167 @@

Access token required

state.elements.fileDelete.focus(); } - function renderSftpEntries(state, directories, files, truncated) { + function compareSftpEntries(left, right, column, direction, directories = false) { + let comparison = 0; + if (column === 'size' && !directories) comparison = left.size - right.size; + else if (column === 'date') comparison = (left.mtime || 0) - (right.mtime || 0); + if (comparison === 0 || (column === 'size' && directories)) { + comparison = left.name.localeCompare(right.name, undefined, { + numeric: true, + sensitivity: 'base', + }); + } + return direction === 'desc' ? -comparison : comparison; + } + + function sortSftpEntries(state, column) { + if (!state || !['name', 'size', 'date'].includes(column)) return; + if (state.sortColumn === column) state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'; + else { + state.sortColumn = column; + state.sortDirection = 'asc'; + } + renderSftpEntries( + state, + state.currentDirectories, + state.currentFiles, + state.currentListTruncated, + { preserveSelection: true }, + ); + } + + function handleSftpTypeahead(state, event) { + if ( + !state + || event.defaultPrevented + || event.ctrlKey + || event.metaKey + || event.altKey + || event.key.length !== 1 + ) return; + const now = performance.now(); + state.typeaheadBuffer = now - state.typeaheadAt <= 500 + ? `${state.typeaheadBuffer}${event.key}` + : event.key; + state.typeaheadAt = now; + const prefix = state.typeaheadBuffer.toLocaleLowerCase(); + const match = [...state.elements.directoryList.querySelectorAll('.sftp-directory-entry')] + .find(button => (button.dataset.entryName || '').toLocaleLowerCase().startsWith(prefix)); + if (!match) return; + event.preventDefault(); + match.focus({ preventScroll: true }); + match.scrollIntoView({ block: 'nearest' }); + } + + function createSftpEntryCell(state, className, text) { + const cell = state.window.document.createElement('span'); + cell.className = className; + cell.textContent = text; + return cell; + } + + function renderSftpEntries(state, directories, files, truncated, options = {}) { if (!state || !state.elements) return; const list = state.elements.directoryList; - if (state.mode !== 'destination') closeSftpFileOperation(state, false); + const selectedFileId = options.preserveSelection && state.selectedRemoteFile + ? state.selectedRemoteFile.fileId + : null; + if (state.mode !== 'destination' && !options.preserveSelection) closeSftpFileOperation(state, false); list.replaceChildren(); - const safeDirectories = Array.isArray(directories) ? directories : []; + state.currentDirectories = (Array.isArray(directories) ? directories : []).filter(entry => ( + entry && typeof entry.name === 'string' + )).map(entry => ({ + name: entry.name, + mtime: Number.isFinite(Number(entry.mtime)) ? Number(entry.mtime) : null, + })); state.currentFiles = (Array.isArray(files) ? files : []).filter(file => ( file - && typeof file.file_id === 'string' + && typeof (file.file_id || file.fileId) === 'string' && typeof file.name === 'string' && Number.isFinite(Number(file.size)) && Number.isFinite(Number(file.mtime)) )).map(file => ({ - fileId: file.file_id, + fileId: file.file_id || file.fileId, name: file.name, size: Number(file.size), mtime: Number(file.mtime), })); + state.currentListTruncated = truncated === true; state.currentEntryNames = new Set([ - ...safeDirectories.filter(entry => entry && typeof entry.name === 'string').map(entry => entry.name), + ...state.currentDirectories.map(entry => entry.name), ...state.currentFiles.map(file => file.name), ]); - if (safeDirectories.length === 0 && state.currentFiles.length === 0) { + + const header = state.window.document.createElement('div'); + header.className = 'sftp-list-header'; + [['name', 'Name'], ['size', 'Size'], ['date', 'Date']].forEach(([column, label]) => { + const button = state.window.document.createElement('button'); + button.type = 'button'; + button.className = `sftp-sort-button${state.sortColumn === column ? ' active' : ''}`; + button.dataset.sortColumn = column; + button.textContent = `${label}${state.sortColumn === column ? (state.sortDirection === 'asc' ? ' ▲' : ' ▼') : ''}`; + button.setAttribute('aria-label', `Sort by ${label}`); + button.setAttribute('aria-pressed', state.sortColumn === column ? 'true' : 'false'); + button.addEventListener('click', () => sortSftpEntries(state, column)); + header.appendChild(button); + }); + list.appendChild(header); + + const directoriesToRender = [...state.currentDirectories].sort((left, right) => ( + compareSftpEntries(left, right, state.sortColumn, state.sortDirection, true) + )); + const filesToRender = [...state.currentFiles].sort((left, right) => ( + compareSftpEntries(left, right, state.sortColumn, state.sortDirection, false) + )); + if (directoriesToRender.length === 0 && filesToRender.length === 0) { const empty = state.window.document.createElement('div'); empty.className = 'sftp-directory-empty'; empty.textContent = 'No subdirectories or files.'; list.appendChild(empty); } else { - safeDirectories.forEach(entry => { - if (!entry || typeof entry.name !== 'string') return; + directoriesToRender.forEach(entry => { const button = state.window.document.createElement('button'); button.type = 'button'; button.className = 'sftp-directory-entry'; - button.textContent = `📁 ${entry.name}`; + button.dataset.entryName = entry.name; button.title = entry.name; + button.setAttribute('aria-label', `Folder ${entry.name}${entry.mtime === null ? '' : `, modified ${formatSftpModified(entry.mtime)}`}.`); + button.append( + createSftpEntryCell(state, 'sftp-file-entry-name', `📁 ${entry.name}`), + createSftpEntryCell(state, 'sftp-file-entry-size', ''), + createSftpEntryCell(state, 'sftp-file-entry-date', formatSftpModified(entry.mtime)), + ); button.addEventListener('click', () => requestSftpBrowse(state, { path: state.currentPath, child: entry.name, })); list.appendChild(button); }); - state.currentFiles.forEach(file => { + filesToRender.forEach(file => { const button = state.window.document.createElement('button'); button.type = 'button'; button.className = 'sftp-directory-entry sftp-file-entry'; button.dataset.fileEntry = 'true'; + button.dataset.entryName = file.name; button.title = getSftpRemoteFilePath(state, file); - button.setAttribute('aria-label', `File ${file.name}, ${formatSftpBytes(file.size)}. Open file actions.`); + button.setAttribute('aria-label', `File ${file.name}, ${formatSftpBytes(file.size)}, modified ${formatSftpModified(file.mtime)}. Open file actions.`); button.setAttribute('aria-pressed', 'false'); - const name = state.window.document.createElement('span'); - name.className = 'sftp-file-entry-name'; - name.textContent = `📄 ${file.name}`; - const size = state.window.document.createElement('span'); - size.className = 'sftp-file-entry-size'; - size.textContent = formatSftpBytes(file.size); - button.append(name, size); + button.append( + createSftpEntryCell(state, 'sftp-file-entry-name', `📄 ${file.name}`), + createSftpEntryCell(state, 'sftp-file-entry-size', formatSftpBytes(file.size)), + createSftpEntryCell(state, 'sftp-file-entry-date', formatSftpModified(file.mtime)), + ); if (state.mode === 'destination') { button.disabled = true; - button.setAttribute('aria-label', `Existing file ${file.name}, ${formatSftpBytes(file.size)}.`); + button.setAttribute('aria-label', `Existing file ${file.name}, ${formatSftpBytes(file.size)}, modified ${formatSftpModified(file.mtime)}.`); } else { button.addEventListener('click', () => showSftpFileOperation(state, file, button)); + if (file.fileId === selectedFileId) { + state.selectedRemoteFile = file; + state.selectedRemoteFileButton = button; + button.classList.add('selected'); + button.setAttribute('aria-pressed', 'true'); + } } list.appendChild(button); }); @@ -7611,8 +7780,29 @@

Access token required

} } + function getSftpUploadFilename(state) { + if (!state || !state.file) return ''; + if (state.mode === 'destination' && state.elements && state.elements.filename) { + return state.elements.filename.value; + } + return state.file.name; + } + function requestSftpUploadTicket(state, conflictMode = 'ask') { if (!state || !state.file || !state.currentPath || state.busy || state.uploading || !socket || !socket.connected) return; + const filename = getSftpUploadFilename(state); + if ( + !filename + || filename === '.' + || filename === '..' + || filename.includes('/') + || filename.includes('\\') + || /[\u0000-\u001f\u007f]/.test(filename) + || new TextEncoder().encode(filename).length > 255 + ) { + setSftpPipStatus(state, 'Enter a valid destination file name.', 'error'); + return; + } const requestId = nextSftpRequestId('sftp-upload'); state.pendingTicketRequestId = requestId; setSftpPipBusy(state, true); @@ -7622,7 +7812,7 @@

Access token required

request_id: requestId, terminal_id: state.terminalId, directory: state.elements.path.value, - filename: state.file.name, + filename, size: state.file.size, conflict_mode: conflictMode, }); @@ -7633,11 +7823,15 @@

Access token required

const file = state.file; state.uploading = true; state.busy = false; + state.finished = false; state.destinationPath = data.destination_path; state.elements.progress.classList.add('visible'); state.elements.progressBar.style.width = file.size === 0 ? '100%' : '0%'; setSftpPipBusy(state, false); setSftpPipStatus(state, `Sending to ${data.destination_path}…`); + if (state.mode === 'destination') { + state.elements.lifecycle.textContent = 'Uploading through the browser. Use Cancel upload to stop the request.'; + } const xhr = new XMLHttpRequest(); state.xhr = xhr; @@ -7662,33 +7856,66 @@

Access token required

state.elements.progressBar.style.width = '0%'; } if (xhr.status >= 200 && xhr.status < 300 && result && result.status === 'completed') { - state.file = null; - updateSftpSelectedFile(state); + if (state.mode === 'destination') { + state.finished = true; + state.elements.lifecycle.textContent = 'Upload finished. Close returns to the previous pane.'; + } else { + state.file = null; + updateSftpSelectedFile(state); + } setSftpPipStatus(state, `Uploaded to ${result.destination_path}.`, 'success'); } else { + if (state.mode === 'destination') { + state.finished = true; + state.elements.lifecycle.textContent = 'Upload stopped. Check the destination before retrying.'; + } setSftpPipStatus(state, (result && result.message) || 'File upload failed.', 'error'); } setSftpPipBusy(state, false); if (!state.window || state.window.closed) { if (xhr.status >= 200 && xhr.status < 300) alert(`SFTP upload completed: ${result.destination_path}`); - sftpPipState = null; + if (sftpPipState === state) sftpPipState = null; } }; xhr.onerror = () => { state.uploading = false; state.xhr = null; + if (state.mode === 'destination') { + state.finished = true; + state.elements.lifecycle.textContent = 'Upload stopped. Check the destination before retrying.'; + } setSftpPipStatus(state, 'File upload failed because the connection was interrupted.', 'error'); setSftpPipBusy(state, false); if (!state.window || state.window.closed) { alert('SFTP upload failed because the connection was interrupted.'); - sftpPipState = null; + if (sftpPipState === state) sftpPipState = null; } }; + xhr.onabort = () => { + state.uploading = false; + state.xhr = null; + state.finished = true; + if (state.elements) { + state.elements.progress.classList.remove('visible'); + state.elements.progressBar.style.width = '0%'; + if (state.mode === 'destination') { + state.elements.lifecycle.textContent = 'Upload request stopped. Check the destination before retrying.'; + } + } + setSftpPipStatus(state, 'Upload request cancelled. Check the destination before retrying.'); + setSftpPipBusy(state, false); + }; xhr.send(file); } function handleSftpUploadTicketResult(data) { - const state = sftpPipState; + const state = [sftpPipState, sftpPipState && sftpPipState.copyDestinationState] + .find(candidate => ( + candidate + && data + && data.terminal_id === candidate.terminalId + && data.request_id === candidate.pendingTicketRequestId + )); if ( !state || !data @@ -7919,36 +8146,24 @@

Access token required

requestSftpBrowse(state, { path: state.currentPath, successMessage: completedMessage }); } - function getSftpCopyDestinationTerminals(sourceTerminalId) { + function getSftpCopyDestinationTerminals(sourceTerminalId, includeSource = false) { return [...terminals.values()].filter(state => ( - state.id !== sourceTerminalId + (includeSource || state.id !== sourceTerminalId) && canUseSftpFileManager(state) )); } - function updateSftpCopySummary(state) { - if (!state || state.mode !== 'destination' || !state.elements) return; - const sourceState = state.sourceState; - const sourceFile = sourceState && sourceState.selectedRemoteFile; - const destinationName = state.elements.filename.value; - const destinationPath = state.currentPath && destinationName - ? `${state.currentPath.endsWith('/') ? state.currentPath : `${state.currentPath}/`}${destinationName}` - : 'Choose a destination folder.'; - state.elements.summary.textContent = sourceFile - ? `${getSftpRemoteFilePath(sourceState, sourceFile)} → ${destinationPath}` - : destinationPath; - setSftpPipBusy(state, state.busy); - } - function closeSftpCopyDestination(sourceState, restoreFocus = true) { if (!sourceState || !sourceState.copyDestinationState) return; const destinationState = sourceState.copyDestinationState; - if (destinationState.copying) { + if (destinationState.copying || destinationState.uploading) { destinationState.elements.lifecycle.textContent = destinationState.cancelPending ? 'Cancellation requested. Wait for backend confirmation before closing Files.' + : (destinationState.uploading + ? 'Cancel the upload before returning to the previous pane.' : (destinationState.copyPhase === 'committing' ? 'Publishing cannot be cancelled. Wait for the result before closing Files.' - : 'Cancel the copy first. Closing Files does not cancel it.'); + : 'Cancel the copy first. Closing Files does not cancel it.')); return; } if (destinationState.elements && destinationState.elements.pane.isConnected) { @@ -7958,18 +8173,21 @@

Access token required

if (sourceState.elements && sourceState.elements.workspace) { sourceState.elements.workspace.classList.remove('copy-open'); } - try { - if (sourceState.window && !sourceState.window.closed) sourceState.window.resizeTo(500, 620); - } catch (_error) {} - if (restoreFocus && sourceState.elements && sourceState.elements.fileCopy) { - sourceState.elements.fileCopy.focus(); + if (restoreFocus && sourceState.elements) { + const focusTarget = destinationState.sourceKind === 'browser' + ? sourceState.elements.localCopy + : sourceState.elements.fileCopy; + if (focusTarget) focusTarget.focus(); } } function selectSftpCopyDestinationTerminal(state, terminalId) { if (!state || state.copying) return; const terminalState = terminals.get(terminalId); - if (!canUseSftpFileManager(terminalState) || terminalId === state.sourceState.terminalId) { + if ( + !canUseSftpFileManager(terminalState) + || (state.sourceKind !== 'browser' && terminalId === state.sourceState.terminalId) + ) { setSftpPipStatus(state, 'Choose another connected Files session.', 'error'); return; } @@ -7979,7 +8197,6 @@

Access token required

state.pendingBrowseRequestId = null; state.elements.endpoint.textContent = formatSftpEndpoint(state.endpoint); requestSftpBrowse(state); - updateSftpCopySummary(state); } function requestSftpCopy(state, conflictMode = 'ask') { @@ -8133,12 +8350,23 @@

Access token required

setSftpPipStatus(state, failureMessage, 'error'); } - function openSftpCopyDestination(sourceState) { - if (!sourceState || !sourceState.selectedRemoteFile || !sourceState.elements) return; - if (sourceState.copyDestinationState) return; - const destinationTerminals = getSftpCopyDestinationTerminals(sourceState.terminalId); + function openSftpCopyDestination(sourceState, sourceKind = 'remote') { + const sourceFile = sourceKind === 'browser' + ? sourceState && sourceState.file + : sourceState && sourceState.selectedRemoteFile; + if (!sourceState || !sourceFile || !sourceState.elements || sourceState.copyDestinationState) return; + const destinationTerminals = getSftpCopyDestinationTerminals( + sourceState.terminalId, + sourceKind === 'browser', + ); if (!destinationTerminals.length) { - setSftpPipStatus(sourceState, 'Open another SSH or Local Shell tab to copy this file.', 'error'); + setSftpPipStatus( + sourceState, + sourceKind === 'browser' + ? 'No connected Files destination is available.' + : 'Open another SSH or Local Shell tab to copy this file.', + 'error', + ); return; } const pane = sourceState.window.document.createElement('div'); @@ -8147,23 +8375,28 @@

Access token required

Choose destination
+
+
Source file
+
+
+
-
Destination endpoint
Direct endpoint
+
Direct SSH or Local Shell endpoint. Nested SSH sessions inside the terminal are not used.
- - + + +
-
+
-
@@ -8174,7 +8407,7 @@

Access token required

Choose a destination.
-
Cancel closes this destination pane before copying.
+
Cancel returns to the previous pane before copying.
@@ -8184,32 +8417,45 @@

Access token required

mode: 'destination', window: sourceState.window, sourceState, + sourceKind, + file: sourceKind === 'browser' ? sourceFile : null, terminalId: destinationTerminals[0].id, endpoint: destinationTerminals[0].sftpEndpoint, currentPath: null, + currentDirectories: [], currentFiles: [], + currentListTruncated: false, currentEntryNames: new Set(), + sortColumn: 'name', + sortDirection: 'asc', + typeaheadBuffer: '', + typeaheadAt: 0, busy: false, copying: false, + uploading: false, copyPhase: 'idle', cancelPending: false, finished: false, pendingBrowseRequestId: null, pendingBrowseStatus: null, + pendingTicketRequestId: null, pendingCopyRequestId: null, copyId: null, copyRevision: -1, + xhr: null, elements: { pane, session: pane.querySelector('.sftp-session-select'), + sourcePath: pane.querySelector('.sftp-copy-source-path'), + sourceMeta: pane.querySelector('.sftp-copy-source-meta'), endpoint: pane.querySelector('.sftp-endpoint-value'), home: pane.querySelector('.sftp-home'), up: pane.querySelector('.sftp-up'), + refresh: pane.querySelector('.sftp-refresh'), path: pane.querySelector('.sftp-path-input'), go: pane.querySelector('.sftp-go'), directoryList: pane.querySelector('.sftp-directory-list'), filename: pane.querySelector('.sftp-copy-name-input'), - summary: pane.querySelector('.sftp-copy-summary'), conflict: pane.querySelector('.sftp-conflict-box'), conflictText: pane.querySelector('.sftp-conflict-text'), conflictCancel: pane.querySelector('.sftp-conflict-cancel'), @@ -8229,14 +8475,20 @@

Access token required

option.textContent = `${terminalState.title || terminalState.label || terminalState.id} · ${terminalState.id}`; destinationState.elements.session.appendChild(option); }); - destinationState.elements.filename.value = sourceState.selectedRemoteFile.name; + destinationState.elements.sourcePath.textContent = sourceKind === 'browser' + ? sourceFile.name + : getSftpRemoteFilePath(sourceState, sourceFile); + destinationState.elements.sourceMeta.textContent = sourceKind === 'browser' + ? `${formatSftpDetailedBytes(sourceFile.size)} ${formatSftpModified(sourceFile.lastModified / 1000)}` + : `${formatSftpDetailedBytes(sourceFile.size)} ${formatSftpModified(sourceFile.mtime)}`; + destinationState.elements.filename.value = sourceFile.name; destinationState.elements.endpoint.textContent = formatSftpEndpoint(destinationState.endpoint); sourceState.copyDestinationState = destinationState; sourceState.elements.workspace.appendChild(pane); sourceState.elements.workspace.classList.add('copy-open'); - try { sourceState.window.resizeTo(960, 620); } catch (_error) {} destinationState.elements.cancel.addEventListener('click', () => { - if (destinationState.copying) requestFilesCopyCancel(destinationState); + if (destinationState.uploading && destinationState.xhr) destinationState.xhr.abort(); + else if (destinationState.copying) requestFilesCopyCancel(destinationState); else closeSftpCopyDestination(sourceState); }); destinationState.elements.session.addEventListener('change', () => { @@ -8247,6 +8499,9 @@

Access token required

path: destinationState.currentPath, parent: true, })); + destinationState.elements.refresh.addEventListener('click', () => requestSftpBrowse(destinationState, { + path: destinationState.currentPath, + })); destinationState.elements.go.addEventListener('click', () => requestSftpBrowse(destinationState, { path: destinationState.elements.path.value, })); @@ -8255,63 +8510,60 @@

Access token required

path: destinationState.elements.path.value, }); }); - destinationState.elements.filename.addEventListener('input', () => updateSftpCopySummary(destinationState)); - destinationState.elements.copy.addEventListener('click', () => requestSftpCopy(destinationState)); + destinationState.elements.filename.addEventListener('input', () => { + setSftpPipBusy(destinationState, destinationState.busy); + }); + destinationState.elements.copy.addEventListener('click', () => { + if (destinationState.sourceKind === 'browser') requestSftpUploadTicket(destinationState); + else requestSftpCopy(destinationState); + }); destinationState.elements.conflictCancel.addEventListener('click', () => { destinationState.elements.conflict.classList.remove('visible'); setSftpPipStatus(destinationState, 'Copy cancelled.'); }); - destinationState.elements.conflictKeep.addEventListener('click', () => requestSftpCopy(destinationState, 'keep_both')); - destinationState.elements.conflictReplace.addEventListener('click', () => requestSftpCopy(destinationState, 'replace')); - updateSftpCopySummary(destinationState); + destinationState.elements.conflictKeep.addEventListener('click', () => { + if (destinationState.sourceKind === 'browser') requestSftpUploadTicket(destinationState, 'keep_both'); + else requestSftpCopy(destinationState, 'keep_both'); + }); + destinationState.elements.conflictReplace.addEventListener('click', () => { + if (destinationState.sourceKind === 'browser') requestSftpUploadTicket(destinationState, 'replace'); + else requestSftpCopy(destinationState, 'replace'); + }); + destinationState.elements.directoryList.addEventListener('keydown', event => handleSftpTypeahead(destinationState, event)); requestSftpBrowse(destinationState); } - function closeSftpPip(state) { - if (!state) return; - const destinationState = state.copyDestinationState; - if (destinationState && destinationState.copying) { - closeSftpCopyDestination(state, false); - return; - } - if (state.busy || state.uploading) return; - const pipWindow = state.window; - if (pipWindow && !pipWindow.closed) pipWindow.close(); - if (sftpPipState === state) sftpPipState = null; - } - function createSftpPipShell(state) { const pipDocument = state.window.document; + pipDocument.title = 'StandTerm - Files'; const shell = pipDocument.createElement('div'); shell.className = 'sftp-pip-shell'; shell.innerHTML = `
-
Files
- +
StandTerm - Files
-
Session endpoint
Direct endpoint
Direct SSH or Local Shell endpoint. Nested SSH sessions inside the terminal are not used.
- - + + +
-
+
`; @@ -8359,21 +8617,21 @@

Access token required

shell, workspace: shell.querySelector('.sftp-files-workspace'), sourcePane: shell.querySelector('.sftp-source-pane'), - close: shell.querySelector('.sftp-pip-close'), endpoint: shell.querySelector('.sftp-endpoint-value'), home: shell.querySelector('.sftp-home'), up: shell.querySelector('.sftp-up'), + refresh: shell.querySelector('.sftp-refresh'), path: shell.querySelector('.sftp-path-input'), go: shell.querySelector('.sftp-go'), directoryList: shell.querySelector('.sftp-directory-list'), fileOperation: shell.querySelector('.sftp-file-operation-box'), fileOperationPath: shell.querySelector('.sftp-file-operation-path'), + fileOperationMeta: shell.querySelector('.sftp-file-operation-meta'), fileOperationActions: shell.querySelector('.sftp-file-operation-actions'), fileDownload: shell.querySelector('.sftp-file-download'), fileCopy: shell.querySelector('.sftp-file-copy'), fileRename: shell.querySelector('.sftp-file-rename'), fileDelete: shell.querySelector('.sftp-file-delete'), - fileOperationClose: shell.querySelector('.sftp-file-operation-close'), renameControls: shell.querySelector('.sftp-rename-controls'), renameInput: shell.querySelector('.sftp-rename-input'), renameConfirm: shell.querySelector('.sftp-rename-confirm'), @@ -8390,6 +8648,8 @@

Access token required

drop: shell.querySelector('.sftp-drop-zone'), fileInput: shell.querySelector('.sftp-file-input'), selectedFile: shell.querySelector('.sftp-selected-file'), + selectedFilePath: shell.querySelector('.sftp-selected-file-path'), + selectedFileMeta: shell.querySelector('.sftp-selected-file-meta'), conflict: shell.querySelector('.sftp-conflict-box'), conflictText: shell.querySelector('.sftp-conflict-text'), conflictCancel: shell.querySelector('.sftp-conflict-cancel'), @@ -8399,15 +8659,15 @@

Access token required

progressBar: shell.querySelector('.sftp-progress-bar'), status: shell.querySelector('.sftp-transfer-status'), send: shell.querySelector('.sftp-send'), + localCopy: shell.querySelector('.sftp-local-copy'), }; - state.elements.close.addEventListener('click', () => closeSftpPip(state)); state.elements.home.addEventListener('click', () => requestSftpBrowse(state)); state.elements.up.addEventListener('click', () => requestSftpBrowse(state, { path: state.currentPath, parent: true })); + state.elements.refresh.addEventListener('click', () => requestSftpBrowse(state, { path: state.currentPath })); state.elements.go.addEventListener('click', () => requestSftpBrowse(state, { path: state.elements.path.value })); state.elements.path.addEventListener('keydown', event => { if (event.key === 'Enter') requestSftpBrowse(state, { path: state.elements.path.value }); }); - state.elements.fileOperationClose.addEventListener('click', () => closeSftpFileOperation(state)); state.elements.fileDownload.addEventListener('click', () => startPreparedSftpDownload(state)); state.elements.fileCopy.addEventListener('click', () => openSftpCopyDestination(state)); state.elements.fileRename.addEventListener('click', () => showSftpRename(state)); @@ -8447,12 +8707,14 @@

Access token required

})); state.elements.drop.addEventListener('drop', event => selectSftpFile(state, event.dataTransfer.files)); state.elements.send.addEventListener('click', () => requestSftpUploadTicket(state)); + state.elements.localCopy.addEventListener('click', () => openSftpCopyDestination(state, 'browser')); state.elements.conflictCancel.addEventListener('click', () => { state.elements.conflict.classList.remove('visible'); setSftpPipStatus(state, 'Upload cancelled.'); }); state.elements.conflictKeep.addEventListener('click', () => requestSftpUploadTicket(state, 'keep_both')); state.elements.conflictReplace.addEventListener('click', () => requestSftpUploadTicket(state, 'replace')); + state.elements.directoryList.addEventListener('keydown', event => handleSftpTypeahead(state, event)); shell.addEventListener('keydown', event => { if (event.key !== 'Escape') return; if (state.elements.deleteConfirmation.classList.contains('visible')) { @@ -8476,15 +8738,22 @@

Access token required

if (sftpPipState.window && !sftpPipState.window.closed) sftpPipState.window.focus(); return; } - const pipWindow = await window.documentPictureInPicture.requestWindow({ width: 480, height: 620 }); + const pipWindow = await window.documentPictureInPicture.requestWindow({ width: 720, height: 620 }); const transferState = { + mode: 'source', window: pipWindow, terminalId: state.id, endpoint: state.sftpEndpoint, currentPath: null, maxUploadBytes: null, + currentDirectories: [], currentFiles: [], + currentListTruncated: false, currentEntryNames: new Set(), + sortColumn: 'name', + sortDirection: 'asc', + typeaheadBuffer: '', + typeaheadAt: 0, file: null, selectedRemoteFile: null, selectedRemoteFileButton: null, @@ -8510,7 +8779,7 @@

Access token required

pipWindow.addEventListener('pagehide', () => { transferState.window = null; transferState.elements = null; - if (!transferState.uploading && sftpPipState === transferState) sftpPipState = null; + if (sftpPipState === transferState) sftpPipState = null; }, { once: true }); requestSftpBrowse(transferState); } diff --git a/terminal_backends/local_shell.py b/terminal_backends/local_shell.py index e4d3905..6eadd21 100644 --- a/terminal_backends/local_shell.py +++ b/terminal_backends/local_shell.py @@ -73,6 +73,7 @@ def file_copy_endpoint(self): return { 'route': 'local', 'shell': self.shell, + 'command': self.shell_command[0] if self.shell_command else self.shell, 'platform': sys.platform, } @@ -268,7 +269,10 @@ def browse_local_files(self, path=None, *, child=None, parent=False, max_entries truncated = True break if is_directory: - directories.append({'name': entry_name}) + directories.append({ + 'name': entry_name, + 'mtime': entry_attributes.st_mtime, + }) continue file_path = canonical_path / entry_name file_snapshot = self._local_file_snapshot(file_path, entry_attributes) diff --git a/terminal_backends/ssh.py b/terminal_backends/ssh.py index 5900287..52a91a6 100644 --- a/terminal_backends/ssh.py +++ b/terminal_backends/ssh.py @@ -277,7 +277,10 @@ def browse_sftp(self, path=None, *, child=None, parent=False, max_entries=1000): truncated = True break if is_directory: - directories.append({'name': entry_name}) + directories.append({ + 'name': entry_name, + 'mtime': entry.st_mtime, + }) elif entry.st_size is not None and entry.st_mtime is not None: file_snapshot = { 'directory': canonical_path, diff --git a/tests/agent_backend_smoke.py b/tests/agent_backend_smoke.py index 04c5ee7..346379d 100644 --- a/tests/agent_backend_smoke.py +++ b/tests/agent_backend_smoke.py @@ -5073,9 +5073,11 @@ def test_local_shell_files_supports_browse_transfer_rename_and_delete(): listing = bridge.browse_local_files(str(root)) assert listing['path'] == str(root.resolve()) - assert listing['directories'] == [{'name': 'folder'}] + assert listing['directories'][0]['name'] == 'folder' + assert isinstance(listing['directories'][0]['mtime'], float) assert [file['name'] for file in listing['files']] == ['source.txt'] assert listing['endpoint']['route'] == 'local' + assert listing['endpoint']['command'] == 'sh' source_entry = listing['files'][0] assert source_entry['file_id'].startswith('localf_') assert 'source.txt' not in source_entry['file_id'] @@ -5741,7 +5743,7 @@ def open_sftp(): bridge._open_sftp = open_sftp browse = bridge.browse_sftp() assert browse['path'] == 'C:/Users/tester' - assert browse['directories'] == [{'name': 'docs'}] + assert browse['directories'] == [{'name': 'docs', 'mtime': 1}] assert len(browse['files']) == 1 assert browse['files'][0]['name'] == 'reference.txt' assert browse['files'][0]['file_id'].startswith('sftpf_') diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index caca86b..ed8a3cf 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -676,7 +676,7 @@ def test_sftp_status_actions_and_terminal_pip_transition(browser, access_url): ) page.click('#sftp-status-btn') page.wait_for_function( - "() => documentPictureInPicture.window?.document.querySelector('.sftp-pip-title')?.textContent === 'Files'", + "() => documentPictureInPicture.window?.document.querySelector('.sftp-pip-title')?.textContent === 'StandTerm - Files'", timeout=5000, ) page.evaluate('() => documentPictureInPicture.window.close()') @@ -733,7 +733,7 @@ def test_sftp_status_actions_and_terminal_pip_transition(browser, access_url): page.evaluate("() => documentPictureInPicture.window.document.querySelector('.pip-sftp-button').click()") page.wait_for_function( - "() => documentPictureInPicture.window?.document.querySelector('.sftp-pip-title')?.textContent === 'Files'", + "() => documentPictureInPicture.window?.document.querySelector('.sftp-pip-title')?.textContent === 'StandTerm - Files'", timeout=5000, ) restored = page.evaluate("() => window.terminalTest.getTerminalTabsState()") @@ -782,15 +782,25 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce pip_state = page.evaluate( """() => ({ title: documentPictureInPicture.window.document.querySelector('.sftp-pip-title')?.textContent, + documentTitle: documentPictureInPicture.window.document.title, hint: documentPictureInPicture.window.document.querySelector('.sftp-direct-hint')?.textContent, hasDropZone: !!documentPictureInPicture.window.document.querySelector('.sftp-drop-zone'), - hasPathInput: !!documentPictureInPicture.window.document.querySelector('.sftp-path-input') + hasPathInput: !!documentPictureInPicture.window.document.querySelector('.sftp-path-input'), + innerCloseCount: documentPictureInPicture.window.document.querySelectorAll('.sftp-pip-close').length, + navigationTitles: [...documentPictureInPicture.window.document.querySelectorAll('.sftp-path-controls .sftp-icon-button')] + .map(button => button.title) })""" ) - check(pip_state['title'] == 'Files', 'Files PiP title was missing') + check(pip_state['title'] == 'StandTerm - Files', 'Files PiP title was missing') + check(pip_state['documentTitle'] == 'StandTerm - Files', 'Files document title was missing') check('Nested SSH sessions' in pip_state['hint'], 'SFTP PiP did not explain the direct endpoint boundary') check(pip_state['hasDropZone'] is True, 'SFTP PiP did not expose a file drop zone') check(pip_state['hasPathInput'] is True, 'SFTP PiP did not expose destination path navigation') + check(pip_state['innerCloseCount'] == 0, 'Files kept a duplicate close control') + check( + pip_state['navigationTitles'] == ['Home directory', 'Parent directory', 'Refresh files list'], + 'Files navigation icons did not expose clear descriptions', + ) page.wait_for_function( "() => documentPictureInPicture.window.document.querySelector('.sftp-transfer-status')?.textContent !== 'Opening Files…'", @@ -799,7 +809,7 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce rendered = page.evaluate( """() => window.terminalTest.renderSftpEntriesForTest({ path: '/home/tester', - directories: [{ name: 'docs' }], + directories: [{ name: 'docs', mtime: 20 }], files: [ { file_id: 'sftpf_random_a', name: 'reference.txt', size: 9, mtime: 25 }, { file_id: 'sftpf_random_b', name: 'existing.txt', size: 4, mtime: 26 } @@ -808,11 +818,51 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce ) check(rendered is True, 'SFTP PiP test fixture could not render remote files') clear_emitted(page) + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-refresh').click()") + refresh_requests = get_emitted(page, 'sftp_browse_request') + check(len(refresh_requests) == 1, 'Files Refresh did not request a new directory listing') + refresh_payload = refresh_requests[0]['args'][0] + check(refresh_payload['path'] == '/home/tester', 'Files Refresh did not keep the current directory') + page.evaluate( + """payload => window.terminalTest.handleSftpBrowseResultForTest({ + request_id: payload.request_id, + terminal_id: 'main', + status: 'ready', + path: '/home/tester', + endpoint: { user: 'tester', host: 'host.example', port: 22, route: 'direct' }, + directories: [{ name: 'docs', mtime: 20 }], + files: [ + { file_id: 'sftpf_random_a', name: 'reference.txt', size: 9, mtime: 25 }, + { file_id: 'sftpf_random_b', name: 'existing.txt', size: 4, mtime: 26 } + ], + truncated: false, + max_upload_bytes: 1024 + })""", + refresh_payload, + ) + clear_emitted(page) file_ui = page.evaluate( """() => { const pipDocument = documentPictureInPicture.window.document; + const rows = () => [...pipDocument.querySelectorAll('.sftp-directory-entry')] + .map(button => button.dataset.entryName); + const sort = column => pipDocument.querySelector(`[data-sort-column="${column}"]`).click(); + const initialRows = rows(); + sort('size'); + sort('size'); + const sizeDescendingRows = rows(); + sort('date'); + sort('date'); + const dateDescendingRows = rows(); + sort('name'); + const list = pipDocument.querySelector('.sftp-directory-list'); + list.focus(); + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true })); + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'e', bubbles: true })); + const typeaheadMatch = pipDocument.activeElement?.dataset.entryName; const files = [...pipDocument.querySelectorAll('.sftp-file-entry')]; - files[0].click(); + const reference = files.find(button => button.dataset.entryName === 'reference.txt'); + reference.click(); const preparing = { disabled: pipDocument.querySelector('.sftp-file-download').disabled, text: pipDocument.querySelector('.sftp-file-download').innerText @@ -831,19 +881,34 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce }); return { fileCount: files.length, + columnHeaders: [...pipDocument.querySelectorAll('.sftp-sort-button')].map(button => button.innerText), + initialRows, + sizeDescendingRows, + dateDescendingRows, + typeaheadMatch, operationVisible: pipDocument.querySelector('.sftp-file-operation-box').classList.contains('visible'), - actions: [...pipDocument.querySelectorAll('.sftp-file-operation-actions button')].map(button => button.innerText), + actions: [...pipDocument.querySelectorAll('.sftp-file-operation-box .sftp-file-operation-actions button')] + .map(button => button.innerText), + operationPath: pipDocument.querySelector('.sftp-file-operation-path').innerText, + operationMeta: pipDocument.querySelector('.sftp-file-operation-meta').innerText, preparing, downloadReady: !pipDocument.querySelector('.sftp-file-download').disabled, - selected: files[0].classList.contains('selected'), - selectedPressed: files[0].getAttribute('aria-pressed'), + selected: reference.classList.contains('selected'), + selectedPressed: reference.getAttribute('aria-pressed'), status: pipDocument.querySelector('.sftp-transfer-status').innerText }; }""" ) check(file_ui['fileCount'] == 2, 'SFTP PiP did not list regular files') + check(file_ui['columnHeaders'] == ['Name ▲', 'Size', 'Date'], 'Files list did not expose sortable columns') + check(file_ui['initialRows'] == ['docs', 'existing.txt', 'reference.txt'], 'Files Name sort did not keep folders first') + check(file_ui['sizeDescendingRows'] == ['docs', 'reference.txt', 'existing.txt'], 'Files Size toggle did not sort descending') + check(file_ui['dateDescendingRows'] == ['docs', 'existing.txt', 'reference.txt'], 'Files Date toggle did not sort descending') + check(file_ui['typeaheadMatch'] == 'reference.txt', 'Files typeahead did not accumulate a quick prefix') check(file_ui['operationVisible'] is True, 'selecting an SFTP file did not open file actions') - check(file_ui['actions'] == ['Download', 'Copy to…', 'Rename…', 'Delete…', 'Close'], 'Files actions were incomplete') + check(file_ui['actions'] == ['Download', 'Copy to…', 'Rename…', 'Delete…'], 'Files actions were incomplete') + check(file_ui['operationPath'] == '/home/tester/reference.txt', 'selected file card omitted the full path') + check('9 B (9 bytes)' in file_ui['operationMeta'] and '1970' in file_ui['operationMeta'], 'selected file card omitted exact file metadata') check(file_ui['preparing'] == {'disabled': True, 'text': 'Preparing…'}, 'SFTP Download was enabled before its ticket was ready') check(file_ui['downloadReady'] is True, 'SFTP Download was not enabled after its ticket became ready') check(file_ui['selected'] is True and file_ui['selectedPressed'] == 'true', 'selected Files row was not highlighted') @@ -901,6 +966,27 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce check(any(message.startswith('[sftp] Download button clicked') for message in browser_console), 'SFTP browser log omitted the explicit click') check(any(message.startswith('[sftp] Download link dispatched') for message in browser_console), 'SFTP browser log omitted the link dispatch') + selection_toggle = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + const row = [...pipDocument.querySelectorAll('.sftp-file-entry')] + .find(button => button.dataset.entryName === 'reference.txt'); + row.click(); + const hiddenAfterToggle = !pipDocument.querySelector('.sftp-file-operation-box').classList.contains('visible'); + row.click(); + return { + hiddenAfterToggle, + visibleAfterToggle: pipDocument.querySelector('.sftp-file-operation-box').classList.contains('visible'), + selectedAfterToggle: row.classList.contains('selected') + }; + }""" + ) + check(selection_toggle == { + 'hiddenAfterToggle': True, + 'visibleAfterToggle': True, + 'selectedAfterToggle': True, + }, 'clicking the selected file did not toggle selection without another action') + clear_emitted(page) copy_picker = page.evaluate( """() => { @@ -914,33 +1000,40 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce status: 'ready', path: '/home/local', endpoint: { route: 'local', shell: 'bash', platform: 'linux' }, - directories: [{ name: 'work' }], + directories: [{ name: 'work', mtime: 40 }], files: [], truncated: false, max_upload_bytes: 1024 }); return { open: pipDocument.querySelector('.sftp-files-workspace').classList.contains('copy-open'), + sourceHidden: pipDocument.defaultView.getComputedStyle(pipDocument.querySelector('.sftp-source-pane')).display === 'none', title: pipDocument.querySelector('.sftp-destination-title').innerText, + sourcePath: pipDocument.querySelector('.sftp-copy-source-path').innerText, + sourceMeta: pipDocument.querySelector('.sftp-copy-source-meta').innerText, sessions: [...pipDocument.querySelector('.sftp-session-select').options].map(option => option.value), path: pipDocument.querySelector('.sftp-destination-pane .sftp-path-input').value, filename: pipDocument.querySelector('.sftp-copy-name-input').value, endpoint: pipDocument.querySelector('.sftp-destination-pane .sftp-endpoint-value').innerText, - innerCloseCount: pipDocument.querySelectorAll('.sftp-destination-close').length, + innerCloseCount: pipDocument.querySelectorAll('.sftp-pip-close, .sftp-destination-close').length, cancelText: pipDocument.querySelector('.sftp-copy-cancel').innerText, - lifecycle: pipDocument.querySelector('.sftp-copy-lifecycle-hint').innerText + lifecycle: pipDocument.querySelector('.sftp-copy-lifecycle-hint').innerText, + duplicateSummaryCount: pipDocument.querySelectorAll('.sftp-copy-summary').length }; }""" ) - check(copy_picker['open'] is True, 'Copy to did not slide out the destination Files pane') + check(copy_picker['open'] is True and copy_picker['sourceHidden'] is True, 'Copy to did not switch to the destination Files pane') check(copy_picker['title'] == 'Choose destination', 'destination Files pane title was unclear') + check(copy_picker['sourcePath'] == '/home/tester/reference.txt', 'destination Files pane omitted the source file path') + check('9 B (9 bytes)' in copy_picker['sourceMeta'], 'destination Files pane omitted exact source file metadata') check(copy_picker['sessions'] == ['term-2'], 'destination Files pane listed an invalid session') check(copy_picker['path'] == '/home/local', 'destination Files pane did not browse the selected session') check(copy_picker['filename'] == 'reference.txt', 'destination Files pane did not preserve the source name') check(copy_picker['endpoint'].startswith('Local Shell'), 'destination Files pane did not identify Local Shell') check(copy_picker['innerCloseCount'] == 0, 'destination Files pane kept a duplicate close icon') check(copy_picker['cancelText'] == 'Cancel', 'destination Files pane did not provide one clear pre-copy exit') - check('closes this destination pane' in copy_picker['lifecycle'], 'destination Files pane did not explain pre-copy cancellation') + check('returns to the previous pane' in copy_picker['lifecycle'], 'destination Files pane did not explain pre-copy cancellation') + check(copy_picker['duplicateSummaryCount'] == 0, 'destination Files pane kept a duplicate instruction card') clear_emitted(page) page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-copy-confirm').click()") @@ -974,10 +1067,6 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce }); const statusAfterForeign = documentPictureInPicture.window.document .querySelector('.sftp-destination-pane .sftp-transfer-status').innerText; - documentPictureInPicture.window.document.querySelector('.sftp-pip-header .sftp-pip-close').click(); - const closeBlocked = !!documentPictureInPicture.window.document.querySelector('.sftp-destination-pane'); - const closeExplanation = documentPictureInPicture.window.document - .querySelector('.sftp-copy-lifecycle-hint').innerText; window.terminalTest.handleFilesCopyResultForTest({ request_id: payload.request_id, copy_id: 'filesc_test', @@ -1020,8 +1109,6 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce }); return { statusAfterForeign, - closeBlocked, - closeExplanation, publishing, terminalButtonText: documentPictureInPicture.window.document .querySelector('.sftp-copy-cancel').innerText, @@ -1032,14 +1119,22 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce copy_payload, ) check(copy_result_ui['statusAfterForeign'].startswith('Copying '), 'Files copy accepted a foreign copy_id with the same request_id') - check(copy_result_ui['closeBlocked'] is True, 'Files closed without distinguishing close from copy cancellation') - check('Cancel the copy first' in copy_result_ui['closeExplanation'], 'Files close did not explain how to stop the copy') check(copy_result_ui['publishing']['text'] == 'Publishing…', 'commit barrier did not replace the cancel action') check(copy_result_ui['publishing']['disabled'] is True, 'commit barrier still allowed cancellation') check('cannot be cancelled' in copy_result_ui['publishing']['lifecycle'], 'commit barrier did not explain its cancellation boundary') check(copy_result_ui['terminalButtonText'] == 'Close', 'completed Files copy did not provide a clear close action') check(copy_result_ui['progressHidden'] is True, 'Files copy did not accept its bound terminal result') - page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-copy-cancel').click()") + back_navigation = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + pipDocument.querySelector('.sftp-copy-cancel').click(); + return { + destinationClosed: !pipDocument.querySelector('.sftp-destination-pane'), + sourceVisible: pipDocument.defaultView.getComputedStyle(pipDocument.querySelector('.sftp-source-pane')).display !== 'none' + }; + }""" + ) + check(back_navigation == {'destinationClosed': True, 'sourceVisible': True}, 'destination Close did not return to the source pane') clear_emitted(page) page.evaluate( @@ -1126,6 +1221,72 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce check('You can close Files' in cancelled_ui['lifecycle'], 'Files copy did not explain the terminal cancellation state') check(cancelled_ui['closed'] is True, 'cancelled Files copy destination pane did not close') + clear_emitted(page) + local_file_picker = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + const input = pipDocument.querySelector('.sftp-file-input'); + const transfer = new DataTransfer(); + transfer.items.add(new File(['upload'], 'local-upload.txt', { + type: 'text/plain', + lastModified: Date.UTC(2026, 8, 2, 2, 20) + })); + input.files = transfer.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + const card = pipDocument.querySelector('.sftp-selected-file'); + const result = { + visible: card.classList.contains('visible'), + path: pipDocument.querySelector('.sftp-selected-file-path').innerText, + meta: pipDocument.querySelector('.sftp-selected-file-meta').innerText, + actions: [...card.querySelectorAll('button')].map(button => button.innerText) + }; + pipDocument.querySelector('.sftp-local-copy').click(); + const request = window.terminalTest.getEmitted() + .find(item => item.event === 'sftp_browse_request' && item.args[0].terminal_id === 'main'); + window.terminalTest.handleSftpBrowseResultForTest({ + request_id: request.args[0].request_id, + terminal_id: 'main', + status: 'ready', + path: '/home/tester', + endpoint: { user: 'tester', host: 'host.example', port: 22, route: 'direct' }, + directories: [], + files: [], + truncated: false, + max_upload_bytes: 1024 + }); + result.sessions = [...pipDocument.querySelector('.sftp-session-select').options] + .map(option => option.value); + result.sourcePath = pipDocument.querySelector('.sftp-copy-source-path').innerText; + return result; + }""" + ) + check(local_file_picker['visible'] is True, 'selected local file card was not shown') + check(local_file_picker['path'] == 'local-upload.txt', 'selected local file card omitted the browser file name') + check('6 B (6 bytes)' in local_file_picker['meta'] and '2026' in local_file_picker['meta'], 'selected local file card omitted exact metadata') + check(local_file_picker['actions'] == ['Send', 'Copy to…'], 'selected local file card actions were unclear') + check(local_file_picker['sessions'] == ['main', 'term-2'], 'local file Copy to omitted an eligible Files destination') + check(local_file_picker['sourcePath'] == 'local-upload.txt', 'local file destination pane omitted its source card') + clear_emitted(page) + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-copy-confirm').click()") + local_upload_requests = get_emitted(page, 'sftp_upload_ticket_request') + check(len(local_upload_requests) == 1, 'local file Copy to did not request an upload ticket') + local_upload_payload = local_upload_requests[0]['args'][0] + check(local_upload_payload['terminal_id'] == 'main', 'local file Copy to targeted the wrong Files session') + check(local_upload_payload['directory'] == '/home/tester', 'local file Copy to lost the destination folder') + check(local_upload_payload['filename'] == 'local-upload.txt', 'local file Copy to lost the destination name') + page.evaluate( + """payload => { + window.terminalTest.handleSftpUploadTicketResultForTest({ + request_id: payload.request_id, + terminal_id: payload.terminal_id, + status: 'failed', + message: 'Test stopped before upload.' + }); + documentPictureInPicture.window.document.querySelector('.sftp-copy-cancel').click(); + }""", + local_upload_payload, + ) + page.evaluate("() => documentPictureInPicture.window.document.querySelector('.sftp-file-rename').click()") rename_initial = page.evaluate( """() => { @@ -1277,6 +1438,27 @@ def test_sftp_send_context_action_is_limited_to_connected_ssh_tabs(browser, acce check(local_menu['sftpVisible'] is True, 'local shell tab did not expose Files') check(local_menu['sftpDisabled'] is False, 'Files was disabled for a capable local shell tab') check('Files' in local_menu['sftpText'], 'local shell Files label was unclear') + page.click('#sftp-send-option') + page.wait_for_function('() => !!window.documentPictureInPicture.window', timeout=5000) + reopened_files = page.evaluate( + """() => { + const pipDocument = documentPictureInPicture.window.document; + return { + title: pipDocument.title, + destinationOpen: !!pipDocument.querySelector('.sftp-destination-pane'), + selectedLocalVisible: pipDocument.querySelector('.sftp-selected-file').classList.contains('visible'), + selectedRemoteVisible: pipDocument.querySelector('.sftp-file-operation-box').classList.contains('visible') + }; + }""" + ) + check(reopened_files == { + 'title': 'StandTerm - Files', + 'destinationOpen': False, + 'selectedLocalVisible': False, + 'selectedRemoteVisible': False, + }, 'reopened Files did not reset to its initial source state') + page.evaluate('() => documentPictureInPicture.window.close()') + page.wait_for_function('() => !window.documentPictureInPicture.window', timeout=5000) finally: close_context(context)