diff --git a/static/css/session-workspace.css b/static/css/session-workspace.css
index 02469ab..9c7c178 100644
--- a/static/css/session-workspace.css
+++ b/static/css/session-workspace.css
@@ -195,6 +195,13 @@
white-space: nowrap;
}
+@media (pointer: coarse) {
+ .fm-embedded-mode .fm-file-item {
+ grid-template-columns: 40px 22px minmax(0, 1fr) auto;
+ min-height: 52px;
+ }
+}
+
.notepad-panel {
min-height: 0;
}
diff --git a/static/css/sftp-file-manager.css b/static/css/sftp-file-manager.css
index 59599bf..85d1774 100644
--- a/static/css/sftp-file-manager.css
+++ b/static/css/sftp-file-manager.css
@@ -393,6 +393,9 @@
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
+ overscroll-behavior: contain;
+ touch-action: pan-y;
+ -webkit-overflow-scrolling: touch;
}
.fm-file-item {
@@ -406,6 +409,8 @@
transition: all 0.15s ease;
user-select: none;
border: 1px solid transparent;
+ touch-action: pan-y;
+ -webkit-tap-highlight-color: transparent;
}
.fm-file-item:hover {
@@ -1260,8 +1265,13 @@
transform: translateY(0);
}
+ .fm-action-sheet.gesture-active {
+ pointer-events: none;
+ }
+
.fm-action-sheet-item {
display: flex;
+ width: 100%;
align-items: center;
gap: 12px;
padding: 14px 16px;
@@ -1272,6 +1282,8 @@
cursor: pointer;
border-radius: 8px;
min-height: 48px;
+ font-family: inherit;
+ text-align: left;
}
.fm-action-sheet-item:active {
@@ -1282,6 +1294,11 @@
color: var(--error-color);
}
+ .fm-action-sheet-item:disabled {
+ cursor: not-allowed;
+ opacity: 0.42;
+ }
+
.fm-action-sheet-cancel {
margin-top: 8px;
padding-top: 8px;
@@ -2691,7 +2708,7 @@
.fm-file-list-header,
#sftpFileManager.fm-workspace-mode .fm-file-item {
- grid-template-columns: 24px 22px minmax(120px, 1fr) 74px;
+ grid-template-columns: 40px 22px minmax(120px, 1fr) 74px;
}
.fm-file-list-header span:nth-last-child(-n+2),
@@ -2700,6 +2717,26 @@
display: none;
}
}
+
+@media (pointer: coarse) {
+ .fm-file-item[draggable="false"] {
+ -webkit-user-drag: none;
+ }
+
+ .fm-file-checkbox {
+ width: 40px;
+ height: 40px;
+ font-size: 20px !important;
+ }
+
+ .fm-pane-toolbar button,
+ .fm-nav-btn,
+ .fm-source-tab-add,
+ .fm-pane-tab {
+ min-width: 44px;
+ min-height: 44px;
+ }
+}
/* SMB quick connection mirrors the compact SFTP connection dialog. */
.smb-source-modal {
z-index: 26000;
diff --git a/static/js/i18n.js b/static/js/i18n.js
index cb523ce..73b5084 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -243,6 +243,7 @@ const translations = {
'fm.workspace.openSourceIn': 'Open source in',
'fm.workspace.selectAll': 'Select all files',
'fm.workspace.selectItem': 'Select',
+ 'fm.workspace.fileActions': 'File actions',
'fm.workspace.transferBetween': 'Transfer between panes',
'fm.workspace.selectFiles': 'Select files',
'fm.workspace.dragToMove': 'Drag items onto a folder to move them',
@@ -1475,6 +1476,7 @@ const translations = {
'fm.workspace.openSourceIn': 'Mở nguồn trong',
'fm.workspace.selectAll': 'Chọn tất cả tệp',
'fm.workspace.selectItem': 'Chọn',
+ 'fm.workspace.fileActions': 'Thao tác tệp',
'fm.workspace.transferBetween': 'Chuyển giữa các khung',
'fm.workspace.selectFiles': 'Chọn tệp',
'fm.workspace.dragToMove': 'Kéo các mục vào một thư mục để di chuyển',
@@ -2706,6 +2708,7 @@ const translations = {
'fm.workspace.openSourceIn': 'Quelle öffnen in',
'fm.workspace.selectAll': 'Alle Dateien auswählen',
'fm.workspace.selectItem': 'Auswählen',
+ 'fm.workspace.fileActions': 'Dateiaktionen',
'fm.workspace.transferBetween': 'Zwischen Bereichen übertragen',
'fm.workspace.selectFiles': 'Dateien auswählen',
'fm.workspace.dragToMove': 'Elemente zum Verschieben auf einen Ordner ziehen',
@@ -3936,6 +3939,7 @@ const translations = {
'fm.workspace.openSourceIn': 'Ouvrir une source dans',
'fm.workspace.selectAll': 'Sélectionner tous les fichiers',
'fm.workspace.selectItem': 'Sélectionner',
+ 'fm.workspace.fileActions': 'Actions sur les fichiers',
'fm.workspace.transferBetween': 'Transférer entre les volets',
'fm.workspace.selectFiles': 'Sélectionner des fichiers',
'fm.workspace.dragToMove': 'Faites glisser les éléments sur un dossier pour les déplacer',
@@ -5166,6 +5170,7 @@ const translations = {
'fm.workspace.openSourceIn': 'Abrir origen en',
'fm.workspace.selectAll': 'Seleccionar todos los archivos',
'fm.workspace.selectItem': 'Seleccionar',
+ 'fm.workspace.fileActions': 'Acciones de archivo',
'fm.workspace.transferBetween': 'Transferir entre paneles',
'fm.workspace.selectFiles': 'Seleccionar archivos',
'fm.workspace.dragToMove': 'Arrastra los elementos a una carpeta para moverlos',
@@ -6396,6 +6401,7 @@ const translations = {
'fm.workspace.openSourceIn': '打开来源于',
'fm.workspace.selectAll': '选择所有文件',
'fm.workspace.selectItem': '选择',
+ 'fm.workspace.fileActions': '文件操作',
'fm.workspace.transferBetween': '在面板之间传输',
'fm.workspace.selectFiles': '选择文件',
'fm.workspace.dragToMove': '将项目拖到文件夹上即可移动',
diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js
index f8fa678..ae1a1fc 100644
--- a/static/js/sftp-file-manager.js
+++ b/static/js/sftp-file-manager.js
@@ -34,6 +34,9 @@ class SFTPFileManager {
this.applyToAll = false;
this.contextMenu = null;
+ this.suppressedItemClick = null;
+ this.actionSheetIndex = null;
+ this.actionSheetTrigger = null;
this.draggedItems = [];
this.dragSource = null;
@@ -1117,17 +1120,17 @@ class SFTPFileManager {
@@ -1183,17 +1186,17 @@ class SFTPFileManager {
@@ -1277,35 +1280,35 @@ class SFTPFileManager {
-
-
- folder_open
- Open
-
-
- download
- Download
-
-
- swap_horiz
- Transfer
-
-
- edit
- Rename
-
-
- create_new_folder
- New Folder
-
-
- delete
- Delete
-
-
- close
- Cancel
-
+
+
+
+
+
+
+
+
`;
@@ -2746,7 +2749,7 @@ class SFTPFileManager {
+ draggable="${this.supportsNativeFileDrag()}">
@@ -2778,7 +2781,12 @@ class SFTPFileManager {
this.updatePaneStatus(pane);
}
- handleItemClick(e, pane, index) {
+ handleItemClick(e, pane, index, { ignoreSuppression = false } = {}) {
+ if (!ignoreSuppression && this.shouldSuppressItemClick(pane, index)) {
+ e.preventDefault?.();
+ e.stopPropagation?.();
+ return;
+ }
e.stopPropagation();
this.setActivePane(pane);
@@ -2809,6 +2817,11 @@ class SFTPFileManager {
}
handleItemDoubleClickEvent(e, pane, index) {
+ if (this.shouldSuppressItemClick(pane, index)) {
+ e.preventDefault?.();
+ e.stopPropagation?.();
+ return;
+ }
if (e.target?.closest?.('.fm-file-checkbox')) return;
e.preventDefault();
e.stopPropagation();
@@ -2877,6 +2890,43 @@ class SFTPFileManager {
return window.innerWidth < 768;
}
+ supportsNativeFileDrag() {
+ return typeof window.matchMedia !== 'function'
+ || !window.matchMedia('(pointer: coarse)').matches;
+ }
+
+ suppressItemClick(pane, index, duration = 800) {
+ this.suppressedItemClick = {
+ pane,
+ index,
+ until: Date.now() + duration,
+ };
+ }
+
+ shouldSuppressItemClick(pane, index) {
+ const suppressed = this.suppressedItemClick;
+ if (!suppressed) return false;
+ if (suppressed.until <= Date.now()) {
+ this.suppressedItemClick = null;
+ return false;
+ }
+ return suppressed.pane === pane && suppressed.index === index;
+ }
+
+ handleTouchItemTap(event, pane, index) {
+ const state = this.panes[pane];
+ const checkboxTapped = Boolean(event.target?.closest?.('.fm-file-checkbox'));
+ const file = index >= 0 ? state.files[index] : null;
+ if (!checkboxTapped && (index === -1 || file?.is_dir)) {
+ event.preventDefault?.();
+ event.stopPropagation?.();
+ this.setActivePane(pane);
+ this.handleItemDblClick(pane, index);
+ return;
+ }
+ this.handleItemClick(event, pane, index, { ignoreSuppression: true });
+ }
+
updateMobilePaneTabs(pane) {
document.querySelectorAll('.fm-pane-tab').forEach(tab => {
const active = tab.dataset.pane === pane;
@@ -2886,64 +2936,154 @@ class SFTPFileManager {
}
setupLongPress() {
+ let gesture = null;
let longPressTimer = null;
const longPressDuration = 500;
+ const movementTolerance = 10;
+
+ const clearLongPressTimer = () => {
+ if (longPressTimer) clearTimeout(longPressTimer);
+ longPressTimer = null;
+ };
+
+ const resetGesture = () => {
+ clearLongPressTimer();
+ gesture = null;
+ };
['left', 'right'].forEach(pane => {
const listEl = document.getElementById(`fm${this.capitalize(pane)}List`);
- listEl.addEventListener('touchstart', (e) => {
- const item = e.target.closest('.fm-file-item');
+ listEl.addEventListener('pointerdown', event => {
+ if (event.pointerType !== 'touch' || gesture) return;
+ const item = event.target.closest('.fm-file-item');
if (!item) return;
-
+ const index = parseInt(item.dataset.index);
+ gesture = {
+ pane,
+ index,
+ item,
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ moved: false,
+ longPressed: false,
+ };
longPressTimer = setTimeout(() => {
- e.preventDefault();
- const index = parseInt(item.dataset.index);
+ if (!gesture || gesture.moved) return;
+ gesture.longPressed = true;
this.setActivePane(pane);
this.panes[pane].selected.clear();
- this.panes[pane].selected.add(index);
+ if (index >= 0) {
+ this.panes[pane].selected.add(index);
+ }
this.updateSelectionVisual(pane);
- this.showActionSheet();
+ this.suppressItemClick(pane, index);
+ this.showActionSheet(pane, index, {
+ gestureActive: true,
+ trigger: item.querySelector('.fm-file-checkbox'),
+ });
}, longPressDuration);
- }, { passive: false });
+ });
- listEl.addEventListener('touchend', () => {
- if (longPressTimer) {
- clearTimeout(longPressTimer);
- longPressTimer = null;
- }
+ listEl.addEventListener('pointermove', event => {
+ if (!gesture || gesture.pointerId !== event.pointerId) return;
+ const deltaX = Math.abs(event.clientX - gesture.startX);
+ const deltaY = Math.abs(event.clientY - gesture.startY);
+ if (Math.max(deltaX, deltaY) <= movementTolerance) return;
+ gesture.moved = true;
+ clearLongPressTimer();
});
- listEl.addEventListener('touchmove', () => {
- if (longPressTimer) {
- clearTimeout(longPressTimer);
- longPressTimer = null;
+ listEl.addEventListener('pointerup', event => {
+ if (!gesture || gesture.pointerId !== event.pointerId) return;
+ const completedGesture = gesture;
+ resetGesture();
+ if (completedGesture.moved) return;
+ if (completedGesture.longPressed) {
+ this.releaseActionSheetGesture();
+ return;
}
+ const releasedItem = event.target.closest('.fm-file-item');
+ if (releasedItem !== completedGesture.item) return;
+ this.handleTouchItemTap(event, pane, completedGesture.index);
+ this.suppressItemClick(pane, completedGesture.index);
+ });
+
+ listEl.addEventListener('pointercancel', () => {
+ const completedGesture = gesture;
+ resetGesture();
+ if (completedGesture?.longPressed) this.releaseActionSheetGesture();
});
});
}
- showActionSheet() {
+ showActionSheet(
+ pane = this.activePane,
+ index = null,
+ { gestureActive = false, trigger = null } = {},
+ ) {
const sheet = document.getElementById('fmActionSheet');
if (sheet) {
+ const state = this.panes[pane];
+ const selectedCount = state?.selected?.size || 0;
+ const targetPane = pane === 'left' ? 'right' : 'left';
+ const setDisabled = (action, disabled) => {
+ const button = sheet.querySelector(`[data-action="${action}"]`);
+ if (button) button.disabled = Boolean(disabled);
+ };
+ this.actionSheetIndex = index;
+ this.actionSheetTrigger = trigger || document.activeElement;
+ setDisabled('open', index === null || (index >= 0 && selectedCount !== 1));
+ setDisabled('download', selectedCount === 0 || !this.sourceCan(state, 'read'));
+ setDisabled('transfer', selectedCount === 0 || !this.canTransferBetweenPanes(pane, targetPane));
+ setDisabled('rename', selectedCount !== 1 || !this.sourceCan(state, 'rename'));
+ setDisabled('newfolder', !this.sourceCan(state, 'mkdir'));
+ setDisabled('delete', selectedCount === 0 || !this.sourceCan(state, 'delete'));
+ sheet.classList.toggle('gesture-active', gestureActive);
sheet.classList.add('visible');
+ sheet.removeAttribute('inert');
+ sheet.setAttribute('aria-hidden', 'false');
+ if (!gestureActive) {
+ sheet.querySelector('.fm-action-sheet-item:not(:disabled)')?.focus();
+ }
}
}
+ releaseActionSheetGesture() {
+ const sheet = document.getElementById('fmActionSheet');
+ if (!sheet?.classList.contains('visible')) return;
+ setTimeout(() => {
+ if (!sheet.classList.contains('visible')) return;
+ sheet.classList.remove('gesture-active');
+ sheet.querySelector('.fm-action-sheet-item:not(:disabled)')?.focus();
+ }, 150);
+ }
+
hideActionSheet() {
const sheet = document.getElementById('fmActionSheet');
+ const trigger = this.actionSheetTrigger;
if (sheet) {
sheet.classList.remove('visible');
+ sheet.classList.remove('gesture-active');
+ sheet.setAttribute('inert', '');
+ sheet.setAttribute('aria-hidden', 'true');
}
+ this.actionSheetIndex = null;
+ this.actionSheetTrigger = null;
+ if (trigger?.isConnected !== false) trigger?.focus?.();
}
handleActionSheetAction(action) {
+ const actionSheetIndex = this.actionSheetIndex;
this.hideActionSheet();
switch (action) {
case 'open':
const state = this.panes[this.activePane];
- if (state.selected.size === 1) {
+ if (Number.isInteger(actionSheetIndex)) {
+ this.handleItemDblClick(this.activePane, actionSheetIndex);
+ } else if (state.selected.size === 1) {
const index = Array.from(state.selected)[0];
this.handleItemDblClick(this.activePane, index);
}
diff --git a/templates/admin.html b/templates/admin.html
index 236f6cc..5a5ebdb 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -448,7 +448,7 @@
Final destru
{% if not embedded %}
-
+
diff --git a/templates/change_password.html b/templates/change_password.html
index f45793e..7d5ee05 100644
--- a/templates/change_password.html
+++ b/templates/change_password.html
@@ -119,7 +119,7 @@ Change Password
-
+