diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js new file mode 100644 index 000000000..d61a801f0 --- /dev/null +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -0,0 +1,3946 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* eslint-disable no-invalid-this */ +/* eslint-disable @stylistic/quotes */ +import path from '../../lib/path.js'; +import open_item from '../../helpers/open_item.js'; +import UIContextMenu from '../UIContextMenu.js'; +import UIWindowProgress from '../UIWindowProgress.js'; +import UIAlert from '../UIAlert.js'; +import generate_file_context_menu from '../../helpers/generate_file_context_menu.js'; +import truncate_filename from '../../helpers/truncate_filename.js'; +import update_title_based_on_uploads from '../../helpers/update_title_based_on_uploads.js'; +import item_icon from '../../helpers/item_icon.js'; +import new_context_menu_item from '../../helpers/new_context_menu_item.js'; +import ContextMenuModal from './ContextMenu/ContextMenu.js'; + +const icons = { + document: ``, + files: ``, + folder: ``, + more: ``, + newFolder: ``, + upload: ``, + trash: ``, + download: ``, + cut: ``, + copy: ``, + restore: ``, + list: ``, + grid: ``, + sort: ``, + select: ``, + done: ``, + worker: ``, +}; + +const { html_encode, SelectionArea } = window; + +/** + * TabFiles - File browser tab component for the Puter Dashboard. + * + * Provides a full-featured file management interface including: + * - Directory navigation with breadcrumb path + * - List and grid view modes + * - File sorting by name, size, or modification date + * - Drag-and-drop file operations (move, copy, shortcut) + * - Context menus for file/folder operations + * - File upload with progress tracking + * - Trash folder support with restore/permanent delete + * + * @module TabFiles + */ +const TabFiles = { + id: 'files', + label: 'Files', + icon: icons.files, + + /** + * Generates the HTML template for the files tab. + * + * @returns {string} HTML string containing the file browser structure + */ + html () { + let h = ` +
+
+ +
+
+
    +
  • Home
  • +
  • Desktop
  • +
  • Documents
  • +
  • Pictures
  • +
  • Public
  • +
  • Videos
  • +
  • Trash
  • +
+
+
+
+
+
+ + + +
+
+
+ + + + + +
+
+
+
+
${i18n('name')}
+
+
${i18n('size')}
+
+
${i18n('modified')}
+
+
+
+
+
+ +
+ + + + + + +
+
+
+ `; + return h; + }, + + /** + * Initializes the files tab with event listeners and state. + * + * Sets up folder click handlers, drag-and-drop zones, context menus, + * and restores persisted preferences (view mode, sort settings, column widths). + * + * @param {jQuery} $el_window - The jQuery-wrapped window/container element + * @returns {Promise} + */ + async init ($el_window) { + this.showSpinner(); + const _this = this; + window.dashboard_object = _this; + + // Dashboard-compatible item creator for use by helpers.js and socket handlers. + // Wraps renderItem() with a directory check so items are only added + // when the user is viewing the relevant directory. + window.UIDashboardFileItem = async function (file) { + if ( ! _this.currentPath ) return; + if ( _this.renderingDirectory ) return; + if ( _this._creatingItem ) return; + + const parentDir = path.dirname(file.path); + if ( _this.currentPath !== parentDir ) return; + + // If item already exists in view, update in-place. + const $existingRow = $(`.files-tab .files .item[data-uid='${file.uid}']`); + if ( $existingRow.length > 0 ) { + // Match renderItem's display name: trashed items show + // metadata.original_name, not the UID that is their raw name. + let displayName = file.name || ''; + try { + const meta = file.metadata ? JSON.parse(file.metadata) : null; + if ( meta && meta.original_name ) displayName = meta.original_name; + } catch { /* keep raw name */ } + $existingRow.attr('data-name', displayName); + $existingRow.attr('data-path', file.path || ''); + $existingRow.attr('data-size', file.size || 0); + $existingRow.attr('data-modified', file.modified || 0); + $existingRow.attr('data-type', file.type || ''); + $existingRow.find('.item-name').text(displayName); + $existingRow.find('.item-name-editor').val(displayName); + if ( + _this.currentView === 'grid' && + typeof file.thumbnail === 'string' && + file.thumbnail.length > 0 + ) { + $existingRow.find('.item-icon img').attr('src', file.thumbnail); + } + return; + } + + await _this.renderItem(file); + + // Get the newly appended row (it's always last after renderItem) + const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${file.uid}']`); + if ( $newRow.length === 0 ) return; + + // Insert at correct sorted position + _this.insertAtSortedPosition($newRow, file); + + // Apply column widths to match existing rows + _this.applyColumnWidths(); + + // Highlight animation to indicate newly added item + $newRow.addClass('item-newly-added'); + }; + + this.renderingDirectory = false; + this._creatingItem = false; + this.activeMenuFileUid = null; + this.currentPath = null; + this.currentPath = null; + this.folderDwellTimer = null; + this.folderDwellTarget = null; + this.springLoadedActive = false; + this.springLoadedOriginalPath = null; + this.previewOpen = false; + this.previewCurrentUid = null; + this.typeSearchTerm = ''; + this.typeSearchTimeout = null; + this.selectModeActive = false; + this.currentView = await puter.kv.get('view_mode') || 'list'; + + // Sorting state + this.sortColumn = await puter.kv.get('sort_column') || 'name'; + this.sortDirection = await puter.kv.get('sort_direction') || 'asc'; + + // Column widths state (for resizing) + const savedWidths = await puter.kv.get('column_widths'); + this.columnWidths = savedWidths ? JSON.parse(savedWidths) : { + name: null, // auto/flex + size: 100, + modified: 120, + }; + + // Add touch-device class for touch devices to show .item-more button + // Use multiple detection methods since user-agent sniffing can miss devices + if ( window.isMobile.phone || window.isMobile.tablet || navigator.maxTouchPoints > 0 ) { + $el_window.find('.files-tab').addClass('touch-device'); + } + + // Create click handler for each folder item + $el_window.find('[data-folder]').each(function () { + const folderElement = this; + + folderElement.onclick = async () => { + const folderPath = folderElement.getAttribute('data-path'); + _this.pushNavHistory(folderPath); + _this.renderDirectory(folderPath); + }; + + // Context menu for sidebar folders + $(folderElement).on('contextmenu taphold', async (e) => { + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + $(folderElement).addClass('context-menu-active'); + const folderPath = folderElement.getAttribute('data-path'); + const items = _this.generateFolderContextMenu(folderPath); + + if ( window.isMobile.phone || window.isMobile.tablet ) { + const modal = new ContextMenuModal({ + onClose: () => $(folderElement).removeClass('context-menu-active'), + }); + modal.show(items, folderElement.getBoundingClientRect()); + } else { + const menu = UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + menu.onClose = () => { + $(folderElement).removeClass('context-menu-active'); + }; + } + }); + + // Make sidebar folders droppable + $(folderElement).droppable({ + accept: '.row', + tolerance: 'pointer', + + drop: async function (event, ui) { + // Clear dwell timer to prevent folder from opening after drop + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + + // Block if ctrl and trashed + const draggedPath = $(ui.draggable).attr('data-path'); + if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + ui.helper.data('dropped', true); + + // Get target folder path from the element itself. Using the + // element's own data-path covers folders (Public, Home) that + // aren't keyed in window.user.directories. + const targetPath = folderElement.getAttribute('data-path'); + + if ( ! targetPath ) return; + + // Collect all items to move + const itemsToMove = [ui.draggable[0]]; + + // Add other selected items + $('.item-selected-clone').each(function () { + const sourceId = $(this).find('.row').attr('data-id'); + const sourceItem = document.querySelector(`.row[data-id="${sourceId}"]`); + if ( sourceItem ) itemsToMove.push(sourceItem); + }); + + // Perform operation based on modifier keys + if ( event.ctrlKey ) { + // Copy + await window.copy_items(itemsToMove, targetPath); + } + else if ( event.altKey && window.feature_flags?.create_shortcut ) { + // Create shortcuts + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + + await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath); + } + } + else { + // Move + await window.move_items(itemsToMove, targetPath); + } + }, + + over: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(folderElement).addClass('active'); + + const folderPath = folderElement.getAttribute('data-path'); + + // Don't auto-open the current directory or trash + if ( folderPath === _this.currentPath || + folderPath === window.trash_path ) { + return; + } + + // Clear any existing dwell timer + clearTimeout(_this.folderDwellTimer); + + // Add visual feedback animation + $(folderElement).addClass('dwell-opening'); + _this.folderDwellTarget = folderElement; + + // Start dwell timer — navigate into folder after 700ms + _this.folderDwellTimer = setTimeout(async () => { + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + if ( ! _this.springLoadedActive ) { + _this.springLoadedOriginalPath = _this.currentPath; + } + _this.springLoadedActive = true; + $('.drag-cancel-zone').show(); + $(folderElement).removeClass('dwell-opening active'); + + _this.pushNavHistory(folderPath); + await _this.renderDirectory(folderPath); + + // Refresh jQuery UI droppable detection for the active drag + if ( $.ui.ddmanager && $.ui.ddmanager.current ) { + $.ui.ddmanager.current.helper.addClass('ui-draggable-dragging'); + $.ui.ddmanager.prepareOffsets($.ui.ddmanager.current); + } + }, 700); + } + }, + + out: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + // Clear dwell timer + if ( _this.folderDwellTarget === folderElement ) { + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + } + $(folderElement).removeClass('dwell-opening'); + + // Only remove active if it's not the currently selected folder + const folderPath = folderElement.getAttribute('data-path'); + + if ( folderPath !== _this.currentPath ) { + $(folderElement).removeClass('active'); + } + } + }, + }); + + // Add native file drop support to sidebar folders + $(folderElement).dragster({ + enter: function (_dragsterEvent, event) { + const e = event.originalEvent; + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const folderPath = folderElement.getAttribute('data-path'); + + // Don't allow drop on trash + if ( folderPath === window.trash_path ) { + return; + } + + $(folderElement).addClass('native-drop-target'); + }, + + leave: function (_dragsterEvent, _event) { + $(folderElement).removeClass('native-drop-target'); + }, + + drop: async function (_dragsterEvent, event) { + const e = event.originalEvent; + $(folderElement).removeClass('native-drop-target'); + + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const folderPath = folderElement.getAttribute('data-path'); + + // Block uploads to trash + if ( folderPath === window.trash_path ) { + return; + } + + if ( e.dataTransfer?.items?.length > 0 ) { + _this.uploadFiles(e.dataTransfer.items, folderPath); + } + + e.stopPropagation(); + e.preventDefault(); + return false; + }, + }); + }); + + // Clear selection when clicking empty area (but not after rubber band selection) + $el_window.find('.dashboard-tab-content').on('click', (e) => { + // Skip if this click is the end of a rubber band selection + if ( _this.rubberBandSelectionJustEnded ) { + _this.rubberBandSelectionJustEnded = false; + return; + } + if ( e.target === e.currentTarget || e.target.classList.contains('files') ) { + document.querySelectorAll('.files-tab .row.selected').forEach(r => { + r.classList.remove('selected'); + }); + _this.updateFooterStats(); + } + }); + + // Right-click on background shows folder context menu + $el_window.find('.files').on('contextmenu taphold', async (e) => { + // Dismiss taphold on non-touch devices + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) { + return; + } + // Only trigger if clicking directly on .files container (not on a row) + if ( e.target.classList.contains('files') || + e.target.classList.contains('files-list-view') || + e.target.classList.contains('files-grid-view') ) { + e.preventDefault(); + e.stopPropagation(); + // Clear selection when right-clicking background + document.querySelectorAll('.files-tab .row.selected').forEach(r => { + r.classList.remove('selected'); + }); + _this.updateFooterStats(); + const items = await _this.generateFolderContextMenu(); + if ( window.isMobile.phone || window.isMobile.tablet ) { + const modal = new ContextMenuModal(); + modal.show(items, e.target.getBoundingClientRect()); + } else { + UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + } + } + }); + + // Store reference to $el_window for later use (must be before createHeaderEventListeners) + this.$el_window = $el_window; + + this.createHeaderEventListeners($el_window); + this.createSelectionActionListeners($el_window); + this.initRubberBandSelection(); + this.initNativeFileDrop(); + + // Apply initial view mode from persisted preferences + + const $filesContainer = this.$el_window.find('.files-tab .files'); + const $tabContent = this.$el_window.find('.files-tab'); + if ( this.currentView === 'grid' ) { + $filesContainer.addClass('files-grid-view'); + $tabContent.addClass('files-grid-mode'); + this.$el_window.find('.view-toggle-btn').html(icons.list); + } else { + $filesContainer.addClass('files-list-view'); + this.$el_window.find('.view-toggle-btn').html(icons.grid); + } + + // Check for initial file path from URL routing + if ( window.dashboard_initial_file_path ) { + const initialPath = window.dashboard_initial_file_path; + delete window.dashboard_initial_file_path; // Clear so it only runs once + this.pushNavHistory(initialPath); + this.renderDirectory(initialPath, { skipUrlUpdate: true }); + } else { + // Auto-select Documents folder on initialization + const documentsFolder = $el_window.find('[data-folder="Documents"]'); + if ( documentsFolder.length ) { + documentsFolder.trigger('click'); + } + } + + // Setup keyboard shortcuts + this.setupKeyboardShortcuts(); + + // Refresh current directory when the user returns to this browser tab + document.addEventListener('visibilitychange', () => { + if ( document.visibilityState === 'visible' && this.currentPath ) { + this.renderDirectory(this.currentPath, { skipNavHistory: true, skipUrlUpdate: true }); + } + }); + }, + + /** + * Called when the Files tab becomes active. + * Updates the URL hash to reflect the current file path. + * + * @param {jQuery} _$el_window - The jQuery-wrapped window/container element (unused) + * @returns {void} + */ + onActivate (_$el_window) { + // Update URL to show current path when Files tab becomes active + if ( this.currentPath && window.is_dashboard_mode ) { + this.updateDashboardUrl(this.currentPath); + } + }, + + /** + * Checks if the Dashboard Files tab is currently active and visible. + * + * @returns {boolean} True if Dashboard is visible and Files tab is active + */ + isDashboardFilesActive () { + if ( !this.$el_window || !this.$el_window.is(':visible') ) return false; + const filesSection = this.$el_window.find('.dashboard-section-files'); + return filesSection.hasClass('active'); + }, + + /** + * Sets up Dashboard-specific keyboard shortcuts. + * + * Handles arrow navigation, selection, copy/cut/paste, delete, rename, etc. + */ + setupKeyboardShortcuts () { + const _this = this; + + $(document).on('keydown.tabfiles', async function (e) { + // Only handle if Dashboard Files tab is active + if ( ! _this.isDashboardFilesActive() ) return; + + const focused_el = document.activeElement; + + // Skip if user is typing in an input/textarea (except for Escape) + if ( $(focused_el).is('input, textarea') && e.which !== 27 ) return; + + // When a context menu is open, yield control to keyboard.js + if ( $('.context-menu').length > 0 ) { + if ( (e.which >= 37 && e.which <= 40) || e.which === 13 || e.which === 27 ) { + return; + } + if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) { + return; + } + } + + const $container = _this.$el_window.find('.files-tab .files'); + const $allRows = $container.find('.row'); + const $selectedRows = $container.find('.row.selected'); + + // F2 - Rename selected item + if ( e.which === 113 ) { + const $selectedRow = $selectedRows.first(); + if ( $selectedRow.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + const $nameEditor = $selectedRow.find('.item-name-editor'); + const $itemName = $selectedRow.find('.item-name'); + if ( $nameEditor.length > 0 ) { + $itemName.hide(); + $nameEditor.show().addClass('item-name-editor-active').focus().select(); + } + } + return false; + } + + // Enter - Open selected items + if ( e.which === 13 && !$(focused_el).hasClass('item-name-editor') ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + $selectedRows.each(function () { + const isDir = $(this).attr('data-is_dir') === '1'; + const itemPath = $(this).attr('data-path'); + if ( isDir ) { + _this.pushNavHistory(itemPath); + _this.renderDirectory(itemPath); + } else { + open_item({ item: this }); + } + }); + } + return false; + } + + // Escape - Cancel drag, clear selection, or cancel rename + if ( e.which === 27 ) { + // Cancel active drag operation + if ( window.an_item_is_being_dragged ) { + e.preventDefault(); + e.stopPropagation(); + + if ( _this.springLoadedActive ) { + _this.navigateBackFromSpringLoad(); + } + _this.springLoadedActive = false; + _this.springLoadedOriginalPath = null; + + // Force jQuery UI to end the drag + $(document).trigger('mouseup'); + + // Cleanup + $('.drag-cancel-zone').remove(); + $('.item-selected-clone').remove(); + $('.draggable-count-badge').remove(); + window.an_item_is_being_dragged = false; + $('.window-app-iframe').css('pointer-events', 'auto'); + return false; + } + + if ( $(focused_el).hasClass('item-name-editor') ) { + // Cancel rename - handled by item's own keyup handler + return; + } + $selectedRows.removeClass('selected'); + _this.updateFooterStats(); + return false; + } + + // Delete - Move to trash or permanently delete + if ( e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey)) ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + + // Check if any items are in trash (for permanent delete) + const trashedItems = $selectedRows.filter(function () { + return $(this).attr('data-path')?.startsWith(`${window.trash_path}/`); + }); + + if ( trashedItems.length > 0 ) { + // Permanent delete with confirmation + const alert_resp = await UIAlert({ + message: i18n('confirm_delete_multiple_items'), + buttons: [ + { label: i18n('delete'), value: 'delete', type: 'primary' }, + { label: i18n('cancel'), value: 'cancel' }, + ], + }); + if ( alert_resp === 'delete' ) { + for ( const row of trashedItems.toArray() ) { + await window.delete_item(row); + } + } + } else { + // Move to trash + await window.move_items($selectedRows.toArray(), window.trash_path); + } + } + return false; + } + + // Ctrl/Cmd + A - Select all + if ( (e.ctrlKey || e.metaKey) && e.which === 65 ) { + e.preventDefault(); + e.stopPropagation(); + $allRows.addClass('selected'); + if ( $allRows.length > 0 ) { + window.active_element = $allRows.last().get(0); + window.latest_selected_item = $allRows.last().get(0); + } + _this.updateFooterStats(); + return false; + } + + // Ctrl/Cmd + C - Copy + if ( (e.ctrlKey || e.metaKey) && e.which === 67 ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + window.clipboard = []; + window.clipboard_op = 'copy'; + $selectedRows.each(function () { + if ( $(this).attr('data-path') !== window.trash_path ) { + window.clipboard.push({ + path: $(this).attr('data-path'), + uid: $(this).attr('data-uid'), + metadata: $(this).attr('data-metadata'), + }); + } + }); + } + return false; + } + + // Ctrl/Cmd + X - Cut + if ( (e.ctrlKey || e.metaKey) && e.which === 88 ) { + if ( $selectedRows.length > 0 ) { + e.preventDefault(); + e.stopPropagation(); + window.clipboard = []; + window.clipboard_op = 'move'; + $selectedRows.each(function () { + window.clipboard.push({ + path: $(this).attr('data-path'), + uid: $(this).attr('data-uid'), + }); + }); + } + return false; + } + + // Ctrl/Cmd + V - Paste + if ( (e.ctrlKey || e.metaKey) && e.which === 86 ) { + if ( window.clipboard.length > 0 && _this.currentPath ) { + e.preventDefault(); + e.stopPropagation(); + // Don't allow paste in Trash unless it's a move operation + if ( _this.currentPath.startsWith(window.trash_path) && window.clipboard_op !== 'move' ) { + return false; + } + if ( window.clipboard_op === 'copy' ) { + window.copy_clipboard_items(_this.currentPath, null); + } else { + _this.moveClipboardItems(_this.currentPath).then(() => { + _this.renderDirectory(_this.currentPath); + }); + } + } + return false; + } + + // Arrow keys - Navigate items + if ( e.which >= 37 && e.which <= 40 ) { + e.preventDefault(); + e.stopPropagation(); + + if ( $allRows.length === 0 ) return false; + + // If nothing selected, select first item + if ( $selectedRows.length === 0 ) { + const $first = $allRows.first(); + $first.addClass('selected'); + window.active_element = $first.get(0); + window.latest_selected_item = $first.get(0); + $first.get(0).scrollIntoView({ block: 'nearest' }); + _this.updateFooterStats(); + return false; + } + + // Find current item and calculate next + const $current = $(window.latest_selected_item || $selectedRows.last().get(0)); + const currentIndex = $allRows.index($current); + let nextIndex = currentIndex; + + // Calculate grid dimensions for grid view + const isGridView = $container.hasClass('files-grid-view'); + let cols = 1; + if ( isGridView && $allRows.length > 1 ) { + const firstTop = $allRows.eq(0).offset().top; + for ( let i = 1; i < $allRows.length; i++ ) { + if ( $allRows.eq(i).offset().top !== firstTop ) { + cols = i; + break; + } + } + if ( cols === 1 ) cols = $allRows.length; // All on one row + } + + // Calculate next index based on arrow key + switch ( e.which ) { + case 37: // Left + nextIndex = Math.max(0, currentIndex - 1); + break; + case 38: // Up + nextIndex = Math.max(0, currentIndex - cols); + break; + case 39: // Right + nextIndex = Math.min($allRows.length - 1, currentIndex + 1); + break; + case 40: // Down + nextIndex = Math.min($allRows.length - 1, currentIndex + cols); + break; + } + + if ( nextIndex !== currentIndex ) { + const $next = $allRows.eq(nextIndex); + + if ( ! e.shiftKey ) { + // Normal navigation - clear selection + $allRows.removeClass('selected'); + } + + $next.addClass('selected'); + window.active_element = $next.get(0); + window.latest_selected_item = $next.get(0); + $next.get(0).scrollIntoView({ block: 'nearest' }); + _this.updateFooterStats(); + + // If preview is open, switch to newly selected file + if ( _this.previewOpen && !e.shiftKey ) { + const newUid = $next.attr('data-uid'); + if ( newUid !== _this.previewCurrentUid ) { + _this.showImagePreview($next); + } + } + } + + return false; + } + + // Space - Toggle image preview + if ( e.which === 32 ) { + e.preventDefault(); + e.stopPropagation(); + + // If preview is open, close it + if ( _this.previewOpen ) { + _this.closeImagePreview(); + return false; + } + + // Open preview for single selected image file + if ( $selectedRows.length === 1 ) { + const $row = $selectedRows.first(); + const isDir = $row.attr('data-is_dir') === '1'; + if ( ! isDir ) { + _this.showImagePreview($row); + } + } + return false; + } + + // Type-to-select: letter/number keys search items by name + if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) { + e.preventDefault(); + e.stopImmediatePropagation(); + + if ( _this.typeSearchTerm !== '' ) { + clearTimeout(_this.typeSearchTimeout); + } + + _this.typeSearchTimeout = setTimeout(() => { + _this.typeSearchTerm = ''; + }, 700); + + _this.typeSearchTerm += e.key.toLocaleLowerCase(); + + let matches = []; + const $currentSelected = $selectedRows.first(); + + // If selected item already matches, keep it + if ( $currentSelected.length === 1 ) { + const selectedName = ($currentSelected.attr('data-name') || '').toLowerCase(); + if ( selectedName.startsWith(_this.typeSearchTerm) ) { + return false; + } + } + + // Search all rows for matches + for ( let j = 0; j < $allRows.length; j++ ) { + const name = ($allRows.eq(j).attr('data-name') || '').toLowerCase(); + if ( name.startsWith(_this.typeSearchTerm) ) { + matches.push($allRows.get(j)); + } + } + + if ( matches.length > 0 ) { + // If multiple matches and one is selected, cycle past it + if ( $currentSelected.length > 0 && matches.length > 1 ) { + let match_index; + for ( let i = 0; i < matches.length - 1; i++ ) { + if ( $(matches[i]).is($currentSelected) ) { + match_index = i; + break; + } + } + if ( match_index !== undefined ) { + matches.splice(0, match_index + 1); + } + } + + // Deselect all, select the match + $allRows.removeClass('selected'); + $(matches[0]).addClass('selected'); + window.active_element = matches[0]; + window.latest_selected_item = matches[0]; + matches[0].scrollIntoView({ block: 'nearest' }); + _this.updateFooterStats(); + } + + return false; + } + }); + }, + + /** + * Shows an image preview popover for the selected file. + * + * Fetches a signed URL for the actual image and displays it in a centered + * popover. The popover can be dismissed by pressing spacebar or clicking outside. + * + * @param {jQuery} $row - The selected row element + * @returns {Promise} + */ + async showImagePreview ($row) { + const uid = $row.attr('data-uid'); + const fileName = $row.attr('data-name'); + const filePath = $row.attr('data-path'); + + // Check if it's an image file + const extension = fileName.split('.').pop().toLowerCase(); + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']; + if ( ! imageExtensions.includes(extension) ) { + return; + } + + // Get read URL for the actual image + const imageUrl = await puter.fs.getReadURL(filePath); + + // Remove any existing preview + $('.image-preview-popover').remove(); + + const $filesContainer = this.$el_window.find('.files-tab .files'); + const containerWidth = $filesContainer.width(); + const containerOffset = $filesContainer.offset(); + + const previewHtml = ` +
+ ${html_encode(fileName)} +
${html_encode(fileName)}
+
+ `; + + $('body').append(previewHtml); + const $popover = $('.image-preview-popover'); + + // Position centered over the files container + $popover.css({ + maxWidth: `${containerWidth - 40}px`, + width: '100%', + left: `${containerOffset.left + (containerWidth / 2)}px`, + top: `${containerOffset.top + ($filesContainer.height() / 2)}px`, + transform: 'translate(-50%, -50%)', + }); + + this.previewOpen = true; + this.previewCurrentUid = uid; + + // Close on click outside the popover. Remove any prior handler first so + // repeated calls (image arrow-navigation) don't stack duplicate handlers. + const _this = this; + $(document).off('click.imagepreview').on('click.imagepreview', (e) => { + if ( ! $(e.target).closest('.image-preview-popover').length ) { + _this.closeImagePreview(); + } + }); + }, + + /** + * Closes the image preview popover. + * + * @returns {void} + */ + closeImagePreview () { + $('.image-preview-popover').remove(); + $(document).off('click.imagepreview'); + this.previewOpen = false; + this.previewCurrentUid = null; + }, + + /** + * Sets up event listeners for header controls. + * + * Handles navigation buttons (back/forward/up), new folder, upload, + * view toggle, sort menu, and column header sorting. + * + * @returns {void} + */ + createHeaderEventListeners () { + const _this = this; + const fileInput = document.querySelector('#upload-file-dialog'); + + const el_window_navbar_back_btn = document.querySelector(`.path-btn-back`); + const el_window_navbar_forward_btn = document.querySelector(`.path-btn-forward`); + const el_window_navbar_up_btn = document.querySelector(`.path-btn-up`); + + // Back button + $(el_window_navbar_back_btn).on('click', function () { + // if history menu is open don't continue + if ( $(el_window_navbar_back_btn).hasClass('has-open-contextmenu') ) { + return; + } + if ( window.dashboard_nav_history_current_position > 0 ) { + window.dashboard_nav_history_current_position--; + const new_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(new_path); + } + }); + + // Back button (hold click) + $(el_window_navbar_back_btn).on('taphold', function () { + let items = []; + const pos = el_window_navbar_back_btn.getBoundingClientRect(); + + for ( let index = window.dashboard_nav_history_current_position - 1; index >= 0; index-- ) { + const history_item = window.dashboard_nav_history[index]; + + items.push({ + html: `${history_item === window.home_path ? i18n('home') : path.basename(history_item)}`, + val: index, + onClick: function (e) { + window.dashboard_nav_history_current_position = e.value; + const new_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(new_path); + }, + }); + } + + if ( items.length > 0 ) { + UIContextMenu({ + position: { top: pos.top + pos.height + 3, left: pos.left }, + parent_element: el_window_navbar_back_btn, + items: items, + }); + } + }); + + // Forward button + $(el_window_navbar_forward_btn).on('click', function () { + // if history menu is open don't continue + if ( $(el_window_navbar_forward_btn).hasClass('has-open-contextmenu') ) { + return; + } + if ( window.dashboard_nav_history_current_position < window.dashboard_nav_history.length - 1 ) { + window.dashboard_nav_history_current_position++; + const target_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(target_path); + } + }); + + // Forward button (hold click) + $(el_window_navbar_forward_btn).on('taphold', function () { + let items = []; + const pos = el_window_navbar_forward_btn.getBoundingClientRect(); + + for ( let index = window.dashboard_nav_history_current_position + 1; index < window.dashboard_nav_history.length; index++ ) { + const history_item = window.dashboard_nav_history[index]; + + items.push({ + html: `${history_item === window.home_path ? i18n('home') : path.basename(history_item)}`, + val: index, + onClick: function (e) { + window.dashboard_nav_history_current_position = e.value; + const new_path = window.dashboard_nav_history[window.dashboard_nav_history_current_position]; + _this.renderDirectory(new_path); + }, + }); + } + + if ( items.length > 0 ) { + UIContextMenu({ + parent_element: el_window_navbar_forward_btn, + position: { top: pos.top + pos.height + 3, left: pos.left }, + items: items, + }); + } + }); + + // Up button + $(el_window_navbar_up_btn).on('click', function () { + if ( _this.currentPath === '/' ) return; + + const target_path = path.resolve(path.join(_this.currentPath, '..')); + _this.pushNavHistory(target_path); + _this.renderDirectory(target_path); + }); + + // New folder button + document.querySelector('.new-folder-btn').onclick = async () => { + if ( ! _this.currentPath ) return; + try { + const result = await puter.fs.mkdir({ + path: `${_this.currentPath}/New Folder`, + rename: true, + overwrite: false, + }); + await _this.renderDirectory(_this.currentPath); + // Find and select the new folder, then activate rename + const newFolderRow = this.$el_window.find(`.files-tab .row[data-name="${result.name}"]`); + if ( newFolderRow.length > 0 ) { + newFolderRow.addClass('selected'); + window.activate_item_name_editor(newFolderRow[0]); + } + } catch ( err ) { + // Folder creation failed silently + } + }; + + // Upload input element + fileInput.onchange = async (e) => { + const files = e.target.files; + if ( !files || files.length === 0 ) return; + + let upload_progress_window; + let opid; + + puter.fs.upload(files, _this.currentPath, { + generateThumbnails: true, + init: async (operation_id, xhr) => { + opid = operation_id; + // create upload progress window + upload_progress_window = await UIWindowProgress({ + title: i18n('upload'), + icon: window.icons['app-icon-uploader.svg'], + operation_id: operation_id, + show_progress: true, + on_cancel: () => { + window.show_save_account_notice_if_needed(); + xhr.abort(); + }, + }); + // add to active_uploads + window.active_uploads[opid] = 0; + }, + // start + start: async function () { + // change upload progress window message to uploading + upload_progress_window.set_status('Uploading'); + upload_progress_window.set_progress(0); + }, + // progress + progress: async function (operation_id, op_progress) { + upload_progress_window.set_progress(op_progress); + // update active_uploads + window.active_uploads[opid] = op_progress; + // update title if window is not visible + if ( document.visibilityState !== 'visible' ) { + update_title_based_on_uploads(); + } + }, + // success + success: function (items) { + // Add action to actions_history for undo ability + const files = []; + if ( typeof items[Symbol.iterator] === 'function' ) { + for ( const item of items ) { + files.push(item.path); + } + } else { + files.push(items.path); + } + window.actions_history.push({ + operation: 'upload', + data: files, + }); + setTimeout(() => { + upload_progress_window.close(); + }, 1000); + window.show_save_account_notice_if_needed(); + // remove from active_uploads + delete window.active_uploads[opid]; + // refresh + _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + // Clear the input value to allow uploading the same file again + fileInput.value = ''; + document.querySelector('form').reset(); + }, + // error + error: async function (err) { + const failedItems = Array.isArray(err?.failedItems) ? err.failedItems : []; + if ( failedItems.length > 0 ) { + const failedSummary = failedItems.map((item) => { + const failedPath = item?.path || item?.name || `item ${item?.requestIndex ?? '?'}`; + const failedMessage = typeof item?.message === 'string' && item.message.length > 0 + ? ` (${item.message})` + : ''; + return `- ${failedPath}${failedMessage}`; + }).join('\n'); + UIAlert(`Some uploads failed:\n${failedSummary}`); + } + upload_progress_window.show_error(i18n('error_uploading_files'), err.message); + // remove from active_uploads + delete window.active_uploads[opid]; + _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + }, + // abort + // eslint-disable-next-line no-unused-vars + abort: async function (operation_id) { + // remove from active_uploads + delete window.active_uploads[opid]; + }, + }); + }; + + // Upload button + document.querySelector('.upload-btn').onclick = async () => { + if ( ! this.currentPath ) return; + fileInput.click(); + }; + + // View toggle button + document.querySelector('.view-toggle-btn').onclick = () => { + this.toggleView(); + }; + + // Sort button (shows dropdown menu) + document.querySelector('.sort-btn').onclick = (e) => { + this.showSortMenu(e); + }; + + // Select mode toggle button (mobile only) + document.querySelector('.select-mode-btn').onclick = () => { + this.toggleSelectMode(); + }; + + // Column header sorting + this.$el_window.find('.header .columns .sortable').on('click', (e) => { + const column = $(e.currentTarget).attr('data-sort'); + if ( column ) { + this.handleSort(column); + } + }); + + // Initialize sort indicators + this.updateSortIndicators(); + + // Column resize handles + this.initColumnResizing(); + }, + + /** + * Creates event listeners for the floating selection action buttons. + * + * @param {jQuery} $el_window - The jQuery-wrapped window/container element + * @returns {void} + */ + createSelectionActionListeners ($el_window) { + const _this = this; + const $actions = $el_window.find('.files-selection-actions'); + + // Restore button (for trash items) + $actions.find('.restore-btn').on('click', async function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + for ( const row of selectedRows ) { + try { + await _this.restoreItem(row); + $(row).fadeOut(150, function () { + $(this).remove(); + }); + } catch ( err ) { + console.error('Failed to restore item:', err); + } + } + _this.updateFooterStats(); + }); + + // Download button + $actions.find('.download-btn').on('click', function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + if ( selectedRows.length >= 2 ) { + window.zipItems(Array.from(selectedRows), _this.currentPath, true); + } + }); + + // Cut button + $actions.find('.cut-btn').on('click', function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + window.clipboard_op = 'move'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ + path: $(row).attr('data-path'), + uid: $(row).attr('data-uid'), + }); + }); + }); + + // Copy button + $actions.find('.copy-btn').on('click', function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + window.clipboard_op = 'copy'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ path: $(row).attr('data-path') }); + }); + }); + + // Delete button + $actions.find('.delete-btn').on('click', async function () { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + + // Check if any items are in trash (for permanent delete) + const anyTrashed = Array.from(selectedRows).some(row => { + const rowPath = $(row).attr('data-path'); + return rowPath?.startsWith(`${window.trash_path}/`); + }); + + if ( anyTrashed ) { + const confirmed = await UIAlert({ + message: i18n('confirm_delete_multiple_items'), + buttons: [ + { label: i18n('delete'), value: 'delete', type: 'primary' }, + { label: i18n('cancel'), value: 'cancel' }, + ], + }); + if ( confirmed === 'delete' ) { + for ( const row of selectedRows ) { + await window.delete_item(row); + } + } + } else { + window.move_items(Array.from(selectedRows), window.trash_path); + } + $actions.removeClass('visible'); + }); + + // Done button (exits select mode on mobile) + $actions.find('.done-btn').on('click', function () { + _this.exitSelectMode(); + }); + }, + + /** + * Updates the state of selection action buttons based on current selection. + * Hides download/copy for trashed items, changes delete label for trash. + * + * @param {Array} selectedRows - The selected row elements + * @returns {void} + */ + updateSelectionActionsState (selectedRows) { + const $actions = this.$el_window.find('.files-selection-actions'); + + const anyTrashed = Array.from(selectedRows).some(row => { + const rowPath = $(row).attr('data-path'); + return rowPath?.startsWith(`${window.trash_path}/`); + }); + + if ( anyTrashed ) { + // Show restore, hide download and copy for trashed items + $actions.find('.restore-btn').show(); + $actions.find('.download-btn').hide(); + $actions.find('.cut-btn').hide(); + $actions.find('.copy-btn').hide(); + // Change delete label to "Delete Permanently" + $actions.find('.delete-btn span').text(i18n('delete_permanently') || 'Delete Permanently'); + } else { + // Hide restore, show normal actions + $actions.find('.restore-btn').hide(); + $actions.find('.download-btn').show(); + $actions.find('.cut-btn').show(); + $actions.find('.copy-btn').show(); + $actions.find('.delete-btn span').text(i18n('delete')); + } + }, + + /** + * Initializes column resize functionality for list view. + * + * Enables drag-to-resize on column headers and persists widths to storage. + * + * @returns {void} + */ + initColumnResizing () { + const _this = this; + const $columns = this.$el_window.find('.header .columns'); + + this.applyColumnWidths(); + + $columns.find('.col-resize-handle').on('mousedown', function (e) { + e.preventDefault(); + e.stopPropagation(); + + const $handle = $(this); + const column = $handle.attr('data-resize'); + const $header = $columns; + const startX = e.pageX; + + // Get the column element to resize + let $targetColumn; + if ( column === 'name' ) { + $targetColumn = $header.find('.item-name'); + } else if ( column === 'size' ) { + $targetColumn = $header.find('.item-size'); + } else if ( column === 'modified' ) { + $targetColumn = $header.find('.item-modified'); + } + + const startWidth = $targetColumn.outerWidth(); + + $(document).on('mousemove.colresize', function (moveEvent) { + const diff = moveEvent.pageX - startX; + let newWidth = Math.max(60, startWidth + diff); // Minimum width of 60px + + // For name column, limit max width + if ( column === 'name' ) { + newWidth = Math.max(100, newWidth); + } + + _this.columnWidths[column] = newWidth; + _this.applyColumnWidths(); + }); + + $(document).on('mouseup.colresize', function () { + $(document).off('mousemove.colresize mouseup.colresize'); + puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)); + }); + }); + + // Double-click on resize handle to auto-fit column to longest content + $columns.find('.col-resize-handle').on('dblclick', function (e) { + e.preventDefault(); + e.stopPropagation(); + + const column = $(this).attr('data-resize'); + const $filesTab = _this.$el_window.find('.files-tab'); + const padding = 16; // 8px padding on each side + let maxWidth = 60; // Minimum width + + if ( column === 'name' ) { + maxWidth = 100; + $filesTab.find('.files.files-list-view .row:not(.header)').each(function () { + const fullName = $(this).attr('data-name'); + if ( fullName ) { + const textWidth = measureTextWidth(fullName) + padding; + maxWidth = Math.max(maxWidth + 10, textWidth); + } + }); + } else if ( column === 'size' ) { + $filesTab.find('.files.files-list-view .row:not(.header) .item-size').each(function () { + const text = $(this).text(); + if ( text ) { + const textWidth = measureTextWidth(text) + padding; + maxWidth = Math.max(maxWidth + 10, textWidth); + } + }); + } else if ( column === 'modified' ) { + $filesTab.find('.files.files-list-view .row:not(.header) .item-modified').each(function () { + const text = $(this).text(); + if ( text ) { + const textWidth = measureTextWidth(text) + padding; + maxWidth = Math.max(maxWidth + 10, textWidth); + } + }); + } + + // Apply the new width + _this.columnWidths[column] = Math.ceil(maxWidth); + _this.applyColumnWidths(); + puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)); + }); + }, + + /** + * Applies the current column widths to the header and file rows. + * Also truncates file names to fit the available width. + * Resets to defaults if saved widths don't fit the current screen. + * + * @returns {void} + */ + applyColumnWidths () { + const $filesTab = this.$el_window.find('.files-tab'); + const $container = $filesTab.find('.files'); + const containerWidth = $container.width(); + + // Fixed widths: icon(24) + spacers(4*3) + more(20) = 56px, plus some margin + const fixedWidth = 56 + 20; + + let nameWidth = this.columnWidths.name; + let sizeWidth = this.columnWidths.size || 100; + let modifiedWidth = this.columnWidths.modified || 120; + + // Check if total width exceeds container width + if ( containerWidth > 0 && nameWidth ) { + const totalWidth = fixedWidth + nameWidth + sizeWidth + modifiedWidth; + if ( totalWidth > containerWidth ) { + // Reset to defaults - columns don't fit + this.columnWidths = { + name: null, + size: 100, + modified: 120, + }; + nameWidth = null; + sizeWidth = 100; + modifiedWidth = 120; + } + } + + const nameCol = nameWidth ? `${nameWidth}px` : 'auto'; + const gridTemplate = `24px ${nameCol} 4px ${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`; + + $filesTab.find('.header .columns').css('grid-template-columns', gridTemplate); + $filesTab.find('.files.files-list-view .row').css('grid-template-columns', gridTemplate); + + // Apply middle-truncation to file names + if ( this.currentView === 'list' && nameWidth ) { + const padding = 16; // 8px padding on each side + const availableWidth = nameWidth - padding; + $filesTab.find('.files.files-list-view .row:not(.header) .item-name').each(function () { + const $name = $(this); + const fullName = $name.closest('.row').attr('data-name'); + if ( fullName ) { + $name.text(truncateFilenameToWidth(fullName, availableWidth)); + } + }); + } else if ( this.currentView === 'list' ) { + // Reset to full names when column is auto-width + $filesTab.find('.files.files-list-view .row:not(.header) .item-name').each(function () { + const $name = $(this); + const fullName = $name.closest('.row').attr('data-name'); + if ( fullName ) { + $name.text(fullName); + } + }); + } else if ( this.currentView === 'grid' ) { + // Apply middle-truncation in grid view + $filesTab.find('.files.files-grid-view .row .item-name').each(function () { + const $name = $(this); + const fullName = $name.closest('.row').attr('data-name'); + if ( fullName ) { + const itemWidth = $name.width() || 156; + $name.text(truncateFilenameToWidth(fullName, itemWidth)); + } + }); + } + }, + + /** + * Updates the sidebar folder selection to match the current path. + * + * @returns {void} + */ + updateSidebarSelection () { + this.$el_window.find('.directories li').removeClass('active'); + + const currentPath = this.currentPath; + if ( ! currentPath ) return; + + this.$el_window.find('[data-path]').each(function () { + const folderPath = this.getAttribute('data-path'); + if ( folderPath === currentPath ) { + this.classList.add('active'); + } + }); + }, + + /** + * Updates header action buttons based on current folder context. + * + * Shows/hides new folder, upload, and empty trash buttons as appropriate. + * + * @param {boolean} isTrashFolder - Whether the current folder is the Trash + * @returns {void} + */ + updateActionButtons (isTrashFolder) { + const $pathActions = this.$el_window.find('.path-actions'); + + if ( isTrashFolder ) { + $pathActions.find('.new-folder-btn, .upload-btn').hide(); + + if ( $pathActions.find('.empty-trash-btn').length === 0 ) { + const emptyTrashBtn = $(``); + $pathActions.append(emptyTrashBtn); + emptyTrashBtn.on('click', () => { + window.empty_trash(); + }); + } + $pathActions.find('.empty-trash-btn').show(); + } else { + $pathActions.find('.new-folder-btn, .upload-btn').show(); + $pathActions.find('.empty-trash-btn').hide(); + } + }, + + /** + * Displays the sort options context menu. + * + * @param {MouseEvent} e - The click event from the sort button + * @returns {void} + */ + showSortMenu (e) { + const _this = this; + + const sortOptions = [ + { column: 'name', label: 'Name' }, + { column: 'size', label: 'Size' }, + { column: 'modified', label: 'Date Modified' }, + ]; + + const items = sortOptions.map(opt => { + const isActive = _this.sortColumn === opt.column; + const directionIcon = _this.sortDirection === 'asc' ? ' ↑' : ' ↓'; + + return { + html: `${opt.label}${isActive ? directionIcon : ''}`, + checked: isActive, + onClick: () => { + _this.handleSort(opt.column); + }, + }; + }); + + UIContextMenu({ + items: items, + position: { left: e.pageX, top: e.pageY }, + }); + }, + + /** + * Sorts an array of files according to current sort settings. + * + * Folders are always sorted before files. Within each group, items are + * sorted by the selected column (name, size, or modified date). + * + * @param {Array} files - Array of file/folder objects to sort + * @returns {Array} Sorted array with folders first, then files + */ + sortFiles (files) { + const folders = files.filter(f => f.is_dir); + const regularFiles = files.filter(f => !f.is_dir); + + const getDisplayName = (file) => { + try { + const metadata = file.metadata ? JSON.parse(file.metadata) : {}; + return (metadata.original_name || file.name).toLowerCase(); + } catch { + return file.name.toLowerCase(); + } + }; + + const sortFn = (a, b) => { + let comparison = 0; + const aName = getDisplayName(a); + const bName = getDisplayName(b); + + switch ( this.sortColumn ) { + case 'name': + comparison = aName.localeCompare(bName); + break; + case 'size': + comparison = (a.size || 0) - (b.size || 0); + break; + case 'modified': + comparison = (a.modified || 0) - (b.modified || 0); + break; + default: + comparison = aName.localeCompare(bName); + } + + return this.sortDirection === 'asc' ? comparison : -comparison; + }; + + folders.sort(sortFn); + regularFiles.sort(sortFn); + + return [...folders, ...regularFiles]; + }, + + /** + * Moves a newly appended row to its correct sorted position among + * existing items. Folders always come before files; within each group, + * items are ordered by the current sortColumn and sortDirection. + * + * @param {jQuery} $newRow - The jQuery-wrapped row element to reposition + * @param {Object} file - The file object with name, size, modified, is_dir + */ + insertAtSortedPosition ($newRow, file) { + const $container = this.$el_window.find('.files-tab .files'); + const $existingRows = $container.find('.item.row').not($newRow); + + if ( $existingRows.length === 0 ) return; + + const newIsDir = !!file.is_dir; + // Compare on the display name (data-name = metadata.original_name || name), + // matching sortFiles and the existing rows below. Using raw file.name here + // mis-sorts trashed items, whose name is the UID and whose real name lives + // in metadata.original_name. + const newName = ($newRow.attr('data-name') || file.name || '').toLowerCase(); + const newSize = file.size || 0; + const newModified = file.modified || 0; + const sortColumn = this.sortColumn; + const sortDirection = this.sortDirection; + + $existingRows.each(function () { + const $existing = $(this); + const existingIsDir = $existing.attr('data-is_dir') === '1'; + + // Folders always come before files + if ( newIsDir && !existingIsDir ) { + $newRow.insertBefore($existing); + return false; + } + if ( !newIsDir && existingIsDir ) { + return true; + } + + // Same type — compare by sort column + let comparison = 0; + switch ( sortColumn ) { + case 'name': + comparison = newName.localeCompare(($existing.attr('data-name') || '').toLowerCase()); + break; + case 'size': + comparison = newSize - (parseInt($existing.attr('data-size')) || 0); + break; + case 'modified': + comparison = newModified - (parseInt($existing.attr('data-modified')) || 0); + break; + default: + comparison = newName.localeCompare(($existing.attr('data-name') || '').toLowerCase()); + } + + if ( sortDirection !== 'asc' ) comparison = -comparison; + + if ( comparison < 0 ) { + $newRow.insertBefore($existing); + return false; + } + }); + + // If not inserted, it belongs at the end (already there from append) + }, + + /** + * Handles sort column selection or direction toggle. + * + * Clicking the same column toggles direction; clicking a new column + * sets ascending order. Persists settings and re-renders the directory. + * + * @param {string} column - Column name to sort by ('name', 'size', or 'modified') + * @returns {Promise} + */ + async handleSort (column) { + if ( this.sortColumn === column ) { + this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.sortColumn = column; + this.sortDirection = 'asc'; + } + + await puter.kv.set('sort_column', this.sortColumn); + await puter.kv.set('sort_direction', this.sortDirection); + + this.updateSortIndicators(); + this.renderDirectory(this.currentPath); + }, + + /** + * Updates visual sort indicators on column headers. + * + * @returns {void} + */ + updateSortIndicators () { + if ( ! this.$el_window ) return; + + const $columns = this.$el_window.find('.header .columns'); + + $columns.find('.sortable').removeClass('sort-asc sort-desc'); + + const $activeColumn = $columns.find(`.sortable[data-sort="${this.sortColumn}"]`); + $activeColumn.addClass(this.sortDirection === 'asc' ? 'sort-asc' : 'sort-desc'); + }, + + /** + * Renders the contents of a directory. + * + * Fetches directory contents, applies sorting, renders each item, + * and updates navigation UI elements. + * + * @param {string} uid - The UID or path of the directory to render + * @param {Object} [options] - Optional settings + * @param {boolean} [options.skipUrlUpdate] - If true, don't update browser URL + * @param {boolean} [options.skipNavHistory] - If true, don't add to navigation history + * @returns {Promise} + */ + async renderDirectory (target, options = {}) { + if ( this.renderingDirectory ) return; + this.renderingDirectory = true; + this.$el_window.find('.files-tab .files').html(''); + this.showSpinner(); + const _this = this; + + document.querySelectorAll('.files-tab .row.selected').forEach(r => { + r.classList.remove('selected'); + }); + + // Determine whether target is a path or uid + const isPath = typeof target === 'string' && target.startsWith('/'); + const readdirArg = isPath + ? { path: target, consistency: options.consistency || 'eventual' } + : { uid: target, consistency: options.consistency || 'eventual' }; + let directoryContents; + try { + directoryContents = await window.puter.fs.readdir(readdirArg); + } catch ( err ) { + // readdir rejects on any backend error (permission, deleted dir, + // network). Without this, renderingDirectory would stay true and + // the guard above would block all further navigation. + console.error('Failed to read directory:', err); + this.hideSpinner(); + this.renderingDirectory = false; + return; + } + if ( ! directoryContents ) { + this.hideSpinner(); + this.renderingDirectory = false; + return; + } + + // Resolve path: if target was a path we already know it, + // otherwise look it up from known user directories. + if ( isPath ) { + this.currentPath = target; + } else { + let path = null; + Object.entries(window.user.directories).forEach(o => { + if ( o[1] === target ) { + path = o[0]; + } + }); + this.currentPath = path || target; + } + + // Update browser URL to reflect current file path (only when Files tab is active) + if ( !options.skipUrlUpdate && window.is_dashboard_mode && this.isDashboardFilesActive() ) { + this.updateDashboardUrl(this.currentPath); + } + + this.updateSidebarSelection(); + + // Filter out hidden files/folders and AppData in home directory + directoryContents = directoryContents.filter(file => { + if ( file.name.startsWith('.') ) return false; + if ( file.name === 'AppData' && this.currentPath === window.home_path ) return false; + return true; + }); + + const isTrashFolder = this.currentPath === window.trash_path; + this.updateActionButtons(isTrashFolder); + + $('.path-breadcrumbs').html(this.renderPath(this.currentPath, window.user.username)); + $('.path-breadcrumbs .dirname').each(function () { + const dirnameElement = this; + const clickedPath = dirnameElement.getAttribute("data-path"); + + dirnameElement.onclick = () => { + _this.pushNavHistory(clickedPath); + _this.renderDirectory(clickedPath); + }; + + $(dirnameElement).on('contextmenu taphold', async (e) => { + // Dismiss taphold on non-touch devices + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + $(dirnameElement).addClass('context-menu-active'); + const items = _this.generateFolderContextMenu(clickedPath); + const menu = UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + menu.onClose = () => { + $(dirnameElement).removeClass('context-menu-active'); + }; + }); + + // Make breadcrumb items droppable for file/folder moves + $(dirnameElement).droppable({ + accept: '.row', + tolerance: 'pointer', + + drop: async function (event, ui) { + const targetPath = $(this).attr('data-path'); + const draggedPath = $(ui.draggable).attr('data-path'); + + // Block copying trashed items + if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + // Don't drop on current directory + if ( targetPath === _this.currentPath ) { + return; + } + + ui.helper.data('dropped', true); + + // Collect all items to move (primary + any selected clones) + const itemsToMove = [ui.draggable[0]]; + $('.item-selected-clone').each(function () { + const sourceId = $(this).find('.row').attr('data-id'); + const sourceItem = document.querySelector(`.row[data-id="${sourceId}"]`); + if ( sourceItem ) itemsToMove.push(sourceItem); + }); + + // Perform operation based on modifier keys + if ( event.ctrlKey ) { + await window.copy_items(itemsToMove, targetPath); + } else if ( event.altKey && window.feature_flags?.create_shortcut ) { + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath); + } + } else { + await window.move_items(itemsToMove, targetPath); + } + }, + + over: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(this).addClass('drop-target'); + } + }, + + out: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(this).removeClass('drop-target'); + } + }, + }); + }); + + if ( directoryContents.length === 0 ) { + this.$el_window.find('.files-tab .files').append(`
+ No files in this directory. + `); + this.updateFooterStats(); + this.updateNavButtonStates(); + this.hideSpinner(); + this.renderingDirectory = false; + return; + } + + const sortedContents = this.sortFiles(directoryContents); + // allSettled so one item that fails to render can't reject the batch, + // which would skip cleanup and leave the tab stuck (renderingDirectory). + await Promise.allSettled(sortedContents.map(file => this.renderItem(file))); + + this.applyColumnWidths(); + this.updateFooterStats(); + this.updateNavButtonStates(); + this.hideSpinner(); + this.renderingDirectory = false; + }, + + /** + * Renders a single file or folder item as a row in the file list. + * + * Creates the DOM element with appropriate data attributes and appends + * it to the files container, then attaches event listeners. + * + * @param {Object} file - The file/folder object from the filesystem API + * @returns {void} + */ + async renderItem (file) { + // For trashed items, use original_name from metadata if available + const item_id = window.global_element_id++; + // metadata is a client-writable, untrusted string stored verbatim, so + // it may be '', undefined, or malformed. Guard the parse (as item_icon.js + // does) — an unguarded throw here aborts the whole directory render. + let metadata = {}; + try { + if ( file.metadata ) metadata = JSON.parse(file.metadata) || {}; + } catch { + metadata = {}; + } + const displayName = metadata.original_name || file.name; + let website_url = window.determine_website_url(file.path); + const is_worker = file.workers?.length > 0; + const worker_url = is_worker ? file.workers[0]?.address : ''; + const iconResult = await item_icon(file); + const icon = ``; + const row = document.createElement("div"); + row.setAttribute('class', `item row ${file.is_dir ? 'folder' : 'file'}`); + row.setAttribute("data-id", item_id); + row.setAttribute("data-name", displayName); + row.setAttribute("data-uid", file.uid); + row.setAttribute("data-is_dir", file.is_dir ? "1" : "0"); + row.setAttribute("data-is_trash", file.is_trash ? "1" : "0"); + row.setAttribute("data-has_website", file.has_website ? "1" : "0"); + row.setAttribute("data-website_url", website_url ? html_encode(website_url) : ''); + row.setAttribute("data-immutable", file.immutable ? "1" : "0"); + row.setAttribute("data-is_shortcut", file.is_shortcut); + row.setAttribute("data-shortcut_to", html_encode(file.shortcut_to)); + row.setAttribute("data-shortcut_to_path", html_encode(file.shortcut_to_path)); + row.setAttribute("data-is_worker", is_worker ? "1" : "0"); + row.setAttribute("data-worker_url", is_worker ? worker_url : "0"); + row.setAttribute("data-sortable", file.sortable ?? 'true'); + row.setAttribute("data-metadata", JSON.stringify(metadata)); + row.setAttribute("data-sort_by", html_encode(file.sort_by) ?? 'name'); + row.setAttribute("data-size", file.size); + row.setAttribute("data-type", html_encode(file.type) ?? ''); + row.setAttribute("data-modified", file.modified); + row.setAttribute("data-associated_app_name", html_encode(file.associated_app?.name) ?? ''); + row.setAttribute("data-path", html_encode(file.path)); + row.innerHTML = ` +
+
+ ${icon} +
+
+ + + + +
+
+
${html_encode(displayName)}
+ +
+
+ +
+
${icons.more}
+ `; + this.$el_window.find('.files-tab .files').append(row); + + this.createItemListeners(row, file); + }, + + /** + * Attaches event listeners to a file/folder row element. + * + * Handles selection, double-click to open, rename functionality, + * context menus, and drag-and-drop operations. + * + * @param {HTMLElement} el_item - The row DOM element + * @param {Object} file - The file/folder object data + * @returns {void} + */ + createItemListeners (el_item, file) { + const _this = this; + const el_item_name = el_item.querySelector(`.item-name`); + const el_item_icon = el_item.querySelector('.item-icon'); + const el_item_name_editor = el_item.querySelector(`.item-name-editor`); + const isFolder = el_item.getAttribute('data-is_dir'); + let website_url = window.determine_website_url(file.path); + let rename_cancelled = false; + let shift_clicked = false; + let itemWasSelectedOnMousedown = false; + let lastPointerType = null; + + el_item.onpointerdown = (e) => { + if ( e.target.classList.contains('item-more') ) return; + if ( el_item.classList.contains('header') ) return; + + // Track pointer type so onclick can distinguish touch from mouse. + lastPointerType = e.pointerType; + + // On touch devices, skip all selection logic here. + // Taps are handled by onclick (opens item) and taphold (context menu), + // so pointerdown never accidentally selects while the user is scrolling. + if ( e.pointerType === 'touch' ) return; + + shift_clicked = false; + + // Track whether item was already selected before this mousedown + itemWasSelectedOnMousedown = el_item.classList.contains('selected'); + + if ( e.which === 3 && el_item.classList.contains('selected') && + el_item.parentElement.querySelectorAll('.row.selected').length > 1 ) { + return; + } + + // Handle Shift+Click for range selection + if ( e.shiftKey && window.latest_selected_item && window.latest_selected_item !== el_item ) { + e.preventDefault(); + shift_clicked = true; + + const allRows = $(el_item).parent().find('.row').toArray(); + const clickedIndex = allRows.indexOf(el_item); + const lastSelectedIndex = allRows.indexOf(window.latest_selected_item); + + if ( clickedIndex !== -1 && lastSelectedIndex !== -1 ) { + const start = Math.min(clickedIndex, lastSelectedIndex); + const end = Math.max(clickedIndex, lastSelectedIndex); + + // Clear selection if no Ctrl/Cmd held + if ( !e.ctrlKey && !e.metaKey ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + } + + // Select all items in range + for ( let i = start; i <= end; i++ ) { + allRows[i].classList.add('selected'); + } + + // Update latest selected to the clicked item + window.latest_selected_item = el_item; + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + _this.updateFooterStats(); + return; + } + } + + // In select mode on mobile, treat taps like Ctrl+click (toggle selection) + const isMobileSelectMode = (window.isMobile.phone || window.isMobile.tablet) && _this.selectModeActive; + + // If clicking on .item-name, .item-icon, or .item-badges, select immediately so item drag works. + // On touch devices, these elements have pointer-events:none via CSS so this path + // won't be reached — touches land on .row instead, deferring selection to onclick. + const isDragHandle = e.target.closest('.item-name, .item-icon, .item-badges'); + if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode && isDragHandle ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + el_item.classList.add('selected'); + window.latest_selected_item = el_item; + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + itemWasSelectedOnMousedown = true; + _this.updateFooterStats(); + return; + } + + // If item is NOT selected and no modifier keys: defer selection to click handler. + // This allows rubberband selection to start when dragging from unselected items. + if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode ) { + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + return; + } + + if ( !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + } + + if ( ! e.shiftKey ) { + if ( ((e.ctrlKey || e.metaKey) || isMobileSelectMode) && el_item.classList.contains('selected') ) { + el_item.classList.remove('selected'); + } else { + el_item.classList.add('selected'); + window.latest_selected_item = el_item; + } + } + + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + _this.updateFooterStats(); + + // If preview is open, switch to newly selected file + if ( _this.previewOpen ) { + const $container = $(el_item).closest('.files'); + const $newSelected = $container.find('.row.selected'); + if ( $newSelected.length === 1 ) { + const newUid = $newSelected.attr('data-uid'); + if ( newUid !== _this.previewCurrentUid ) { + _this.showImagePreview($newSelected); + } + } + } + }; + + el_item.onclick = (e) => { + if ( e.target.classList.contains('item-more') ) { + this.handleMoreClick(el_item, file, e.target); + return; + } + + // Skip if this click is the end of a rubber band selection + if ( _this.rubberBandSelectionJustEnded ) { + _this.rubberBandSelectionJustEnded = false; + return; + } + + // Skip if this was a shift-click (already handled in pointerdown) + if ( shift_clicked ) { + shift_clicked = false; + return; + } + + // On touch/mobile: tap opens the item directly, no selection step needed. + // Selection (and context menu) is handled via taphold. + // Use lastPointerType as primary signal (works even if isMobile misdetects). + if ( lastPointerType === 'touch' || window.isMobile.phone || window.isMobile.tablet ) { + // In select mode, tap toggles selection instead of opening + if ( _this.selectModeActive ) { + el_item.classList.toggle('selected'); + window.latest_selected_item = el_item; + _this.updateFooterStats(); + return; + } + if ( isFolder === "1" ) { + _this.pushNavHistory(file.path); + _this.renderDirectory(file.path); + } else { + open_item({ item: el_item }); + } + return; + } + + if ( !e.ctrlKey && !e.metaKey && !e.shiftKey ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + if ( r !== el_item ) r.classList.remove('selected'); + }); + // Ensure clicked item is selected (handles deferred selection from pointerdown) + if ( ! el_item.classList.contains('selected') ) { + el_item.classList.add('selected'); + window.latest_selected_item = el_item; + } + } + _this.updateFooterStats(); + + // If preview is open, switch to newly selected file + if ( _this.previewOpen ) { + const $container = $(el_item).closest('.files'); + const $newSelected = $container.find('.row.selected'); + if ( $newSelected.length === 1 ) { + const newUid = $newSelected.attr('data-uid'); + if ( newUid !== _this.previewCurrentUid ) { + _this.showImagePreview($newSelected); + } + } + } + }; + + el_item.ondblclick = (e) => { + if ( e.target.classList.contains('item-name-editor') ) { + return; + } + if ( isFolder === "1" ) { + _this.pushNavHistory(file.path); + _this.renderDirectory(file.path); + } else { + open_item({ item: el_item }); + } + el_item.classList.remove('selected'); + }; + + // -------------------------------------------------------- + // Rename + // -------------------------------------------------------- + function rename () { + if ( rename_cancelled ) { + rename_cancelled = false; + return; + } + + const old_name = $(el_item).attr('data-name'); + const old_path = $(el_item).attr('data-path'); + const new_name = $(el_item_name_editor).val(); + + // Don't send a rename request if: + // the new name is the same as the old one, + // or it's empty, + // or editable was not even active at all + if ( old_name === new_name || !new_name || new_name === '.' || new_name === '..' || !$(el_item_name_editor).hasClass('item-name-editor-active') ) { + if ( new_name === '.' ) { + UIAlert('The name "." is not allowed, because it is a reserved name. Please choose another name.'); + } + else if ( new_name === '..' ) { + UIAlert('The name ".." is not allowed, because it is a reserved name. Please choose another name.'); + } + $(el_item_name).html(html_encode(truncate_filename(file.name))); + $(el_item_name).show(); + $(el_item_name_editor).val($(el_item).attr('data-name')); + $(el_item_name_editor).hide(); + return; + } + // deactivate item name editable + $(el_item_name_editor).removeClass('item-name-editor-active'); + + // Perform rename request + window.rename_file(file, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, false, (new_name) => { + $(el_item_name).html(html_encode(new_name)); + }); + } + + // -------------------------------------------------------- + // Rename if enter pressed on Item Name Editor + // -------------------------------------------------------- + $(el_item_name_editor).on('keypress', function (e) { + // If name editor is not active don't continue + if ( ! $(el_item_name_editor).is(':visible') ) + { + return; + } + + // Enter key = rename + if ( e.which === 13 ) { + e.stopPropagation(); + e.preventDefault(); + $(el_item_name_editor).blur(); + $(el_item).addClass('selected'); + window.last_enter_pressed_to_rename_ts = Date.now(); + window.update_explorer_footer_selected_items_count($(el_item).closest('.item-container')); + return false; + } + }); + + // -------------------------------------------------------- + // Cancel and undo if escape pressed on Item Name Editor + // -------------------------------------------------------- + $(el_item_name_editor).on('keyup', function (e) { + if ( ! $(el_item_name_editor).is(':visible') ) + { + return; + } + + // Escape = undo rename + else if ( e.which === 27 ) { + e.stopPropagation(); + e.preventDefault(); + rename_cancelled = true; + $(el_item_name_editor).hide(); + $(el_item_name_editor).val(file.name); + $(el_item_name).show(); + } + }); + + $(el_item_name_editor).on('focusout', function (e) { + e.stopPropagation(); + e.preventDefault(); + rename(); + }); + + // Right-click context menu handler (desktop) and taphold (touch devices) + $(el_item).on('contextmenu taphold', async (e) => { + // Dismiss taphold on non-touch devices + if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet && !(navigator.maxTouchPoints > 0) ) { + return; + } + // On iOS, both contextmenu and taphold can fire for the same long-press. + // Debounce to prevent duplicate modals. + if ( el_item._contextMenuShownAt && Date.now() - el_item._contextMenuShownAt < 500 ) { + e.preventDefault(); + return; + } + el_item._contextMenuShownAt = Date.now(); + e.preventDefault(); + e.stopPropagation(); + + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + let items; + if ( selectedRows.length > 1 && el_item.classList.contains('selected') ) { + items = await _this.generateMultiSelectContextMenu(selectedRows); + } else { + items = await _this.generateContextMenuItems(el_item, file); + } + + if ( window.isMobile.phone || window.isMobile.tablet || navigator.maxTouchPoints > 0 ) { + const modal = new ContextMenuModal(); + modal.show(items, el_item.getBoundingClientRect(), { title: file.name }); + } else { + UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } }); + } + }); + + // Skip header row for drag-and-drop + if ( el_item.classList.contains('header') ) return; + + $(el_item).draggable({ + appendTo: 'body', + refreshPositions: true, + helper: function () { + const $clone = $(el_item).clone(); + + // Wrap in container structure so CSS selectors match + const viewClass = _this.currentView === 'grid' ? 'files-grid-view' : 'files-list-view'; + const $wrapper = $(`
`); + $wrapper.find('.files').append($clone); + + // In grid view, set fixed width since the grid auto-fill + // doesn't work without a proper parent width context + if ( _this.currentView === 'grid' ) { + $clone.css('width', $(el_item).outerWidth()); + $wrapper.find('.files').css('display', 'block'); + } + + return $wrapper; + }, + revert: 'invalid', + zIndex: 10000, + scroll: false, + distance: 5, + revertDuration: 100, + + start: function (_event, ui) { + // Don't start drag if item wasn't already selected before mousedown; + // rubberband selection should handle this case instead. + if ( ! itemWasSelectedOnMousedown ) { + return false; + } + + if ( $(el_item).attr('data-immutable') !== '0' ) { + return false; + } + + if ( ! el_item.classList.contains('selected') ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + el_item.classList.add('selected'); + } + + ui.helper.addClass('selected'); + + // Clone other selected items with proper container structure + const viewClass = _this.currentView === 'grid' ? 'files-grid-view' : 'files-list-view'; + $(el_item).siblings('.row.selected').each(function () { + const $clone = $(this).clone(); + const $wrapper = $(`
`); + $wrapper.find('.files').append($clone); + $wrapper.css('position', 'absolute').appendTo('body').hide(); + }); + + const itemCount = $('.item-selected-clone').length; + if ( itemCount > 0 ) { + $('body').append(`${itemCount + 1}`); + } + + window.an_item_is_being_dragged = true; + $('.window-app-iframe').css('pointer-events', 'none'); + + // Create hidden cancel zone (shown when spring-load activates) + const $cancelZone = $(``); + _this.$el_window.find('.dashboard-section-files').append($cancelZone); + $cancelZone.droppable({ + accept: '.row', + tolerance: 'pointer', + over: function () { + $(this).addClass('drag-cancel-hover'); + }, + out: function () { + $(this).removeClass('drag-cancel-hover'); + }, + drop: function (_event, ui) { + ui.helper.data('dropped', true); + ui.helper.data('cancelled', true); + }, + }); + }, + + drag: function (event, ui) { + // Show helpers after 5px movement + if ( Math.abs(ui.originalPosition.top - ui.offset.top) > 5 || + Math.abs(ui.originalPosition.left - ui.offset.left) > 5 ) { + ui.helper.show(); + $('.item-selected-clone').show(); + $('.draggable-count-badge').show(); + } + + $('.draggable-count-badge').css({ + top: event.pageY, + left: event.pageX + 10, + }); + + $('.item-selected-clone').each(function (i) { + $(this).css({ + left: ui.position.left + 3 * (i + 1), + top: ui.position.top + 3 * (i + 1), + 'z-index': 999 - i, + 'opacity': 0.5 - i * 0.1, + }); + }); + }, + + stop: function (event, ui) { + const _this = TabFiles; + + // Clean up dwell state from any folder we were hovering over + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + $('.dwell-opening').removeClass('dwell-opening'); + + // Handle spring-loaded folder drag resolution + if ( _this.springLoadedActive ) { + if ( ui.helper.data('cancelled') ) { + // Dropped on cancel zone → navigate back, no move + _this.navigateBackFromSpringLoad(); + } else if ( ! ui.helper.data('dropped') ) { + // Not dropped on a specific target — check if within .files area + const filesEl = _this.$el_window.find('.files')[0]; + const rect = filesEl.getBoundingClientRect(); + const inFiles = event.clientX >= rect.left && event.clientX <= rect.right && + event.clientY >= rect.top && event.clientY <= rect.bottom; + + if ( inFiles ) { + // Dropped in file list but not on a folder → move to current dir + const itemsToMove = [el_item]; + $('.item-selected-clone').find('.row').each(function () { + itemsToMove.push(this); + }); + + if ( event.ctrlKey ) { + window.copy_items(itemsToMove, _this.currentPath); + } + else if ( event.altKey && window.feature_flags?.create_shortcut ) { + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + window.create_shortcut(itemName, isDir, _this.currentPath, null, shortcutTo, shortcutToPath); + } + } + else { + window.move_items(itemsToMove, _this.currentPath); + } + } else { + // Dropped outside file list → cancel, navigate back + _this.navigateBackFromSpringLoad(); + } + } + // If dropped on a specific folder/breadcrumb target, the drop + // handler already processed it — nothing to do here. + } + + _this.springLoadedActive = false; + _this.springLoadedOriginalPath = null; + $('.drag-cancel-zone').remove(); + $('.item-selected-clone').remove(); + $('.draggable-count-badge').remove(); + window.an_item_is_being_dragged = false; + $('.window-app-iframe').css('pointer-events', 'auto'); + }, + }); + + if ( file.is_dir ) { + $(el_item).droppable({ + accept: '.row', + tolerance: 'pointer', + + drop: async function (event, ui) { + const _this = TabFiles; + + // Clear dwell timer to prevent folder from opening after drop + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + + const draggedPath = $(ui.draggable).attr('data-path'); + if ( event.ctrlKey && draggedPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + ui.helper.data('dropped', true); + + const itemsToMove = [ui.draggable[0]]; + + $('.item-selected-clone').each(function () { + const sourceId = $(this).find('.row').attr('data-id'); + const sourceItem = document.querySelector(`.row[data-id="${sourceId}"]`); + if ( sourceItem ) itemsToMove.push(sourceItem); + }); + + const targetPath = $(el_item).attr('data-path'); + + if ( event.ctrlKey ) { + // Copy + await window.copy_items(itemsToMove, targetPath); + } + else if ( event.altKey && window.feature_flags?.create_shortcut ) { + // Create shortcuts + for ( const item of itemsToMove ) { + const itemPath = $(item).attr('data-path'); + const itemName = itemPath.split('/').pop(); + const isDir = $(item).attr('data-is_dir') === '1'; + const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid'); + const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath; + + await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath); + } + } + else { + await window.move_items(itemsToMove, targetPath); + } + }, + + over: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(el_item).addClass('selected'); + + const _this = TabFiles; + const targetPath = $(el_item).attr('data-path'); + + // Don't auto-open the current directory or trash + if ( targetPath === _this.currentPath || + targetPath === window.trash_path || + targetPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + // Clear any existing dwell timer + clearTimeout(_this.folderDwellTimer); + + // Add visual feedback animation + $(el_item).addClass('dwell-opening'); + _this.folderDwellTarget = el_item; + + // Start dwell timer — navigate into folder after 700ms + _this.folderDwellTimer = setTimeout(async () => { + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + if ( ! _this.springLoadedActive ) { + _this.springLoadedOriginalPath = _this.currentPath; + } + _this.springLoadedActive = true; + $('.drag-cancel-zone').show(); + $(el_item).removeClass('dwell-opening selected'); + + _this.pushNavHistory(targetPath); + _this.renderDirectory(targetPath); + + // Refresh jQuery UI droppable detection for the active drag + if ( $.ui.ddmanager && $.ui.ddmanager.current ) { + $.ui.ddmanager.current.helper.addClass('ui-draggable-dragging'); + $.ui.ddmanager.prepareOffsets($.ui.ddmanager.current); + } + }, 700); + } + }, + + out: function (_event, ui) { + if ( $(ui.draggable).hasClass('row') ) { + $(el_item).removeClass('selected dwell-opening'); + + const _this = TabFiles; + if ( _this.folderDwellTarget === el_item ) { + clearTimeout(_this.folderDwellTimer); + _this.folderDwellTimer = null; + _this.folderDwellTarget = null; + } + } + }, + }); + + // Add native file drop support to folder rows + $(el_item).dragster({ + enter: function (_dragsterEvent, event) { + const e = event.originalEvent; + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const targetPath = $(el_item).attr('data-path'); + + // Don't allow drop on trash folder + if ( targetPath === window.trash_path || + targetPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + $(el_item).addClass('native-drop-target'); + }, + + leave: function (_dragsterEvent, _event) { + $(el_item).removeClass('native-drop-target'); + }, + + drop: async function (_dragsterEvent, event) { + const e = event.originalEvent; + $(el_item).removeClass('native-drop-target'); + + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + const targetPath = $(el_item).attr('data-path'); + + // Block uploads to trash + if ( targetPath === window.trash_path || + targetPath?.startsWith(`${window.trash_path}/`) ) { + return; + } + + if ( e.dataTransfer?.items?.length > 0 ) { + TabFiles.uploadFiles(e.dataTransfer.items, targetPath); + } + + e.stopPropagation(); + e.preventDefault(); + return false; + }, + }); + } + }, + + /** + * Restores a trashed item to its original location. + * + * This is a simplified restore function for the dashboard that calls + * puter.fs.move() directly, avoiding the complexity of window.move_items() + * which is designed for the desktop window system. + * + * @param {HTMLElement} el_item - The row element representing the trashed item + * @returns {Promise} The result from puter.fs.move() + */ + async restoreItem (el_item) { + const uid = $(el_item).attr('data-uid'); + const metadataStr = $(el_item).attr('data-metadata'); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + + if ( ! metadata.original_path ) { + throw new Error('Cannot restore: original path not found in metadata'); + } + + const destPath = path.dirname(metadata.original_path); + const originalName = metadata.original_name; + + const resp = await puter.fs.move({ + source: uid, + destination: destPath, + newName: originalName, + newMetadata: {}, + createMissingParents: true, + }); + + return resp; + }, + + /** + * Moves clipboard items to the specified destination path. + * + * This is a Dashboard-specific implementation that calls puter.fs.move() + * directly, bypassing window.move_clipboard_items() which relies on + * .item DOM elements that don't exist in the Dashboard. + * + * @param {string} destPath - The destination folder path + * @returns {Promise} + */ + async moveClipboardItems (destPath) { + if ( !window.clipboard || window.clipboard.length === 0 ) { + return; + } + + for ( const item of window.clipboard ) { + // Handle both object format { path, uid } and legacy string format + const source = item.uid || item.path || item; + try { + await puter.fs.move({ + source: source, + destination: destPath, + }); + } catch ( err ) { + console.error('Failed to move item:', err); + } + } + + window.clipboard = []; + }, + + /** + * Formats a byte count into a human-readable size string. + * + * @param {number} bytes - The size in bytes + * @returns {string} Formatted size string (e.g., "1.5 MB") + */ + formatFileSize (bytes) { + if ( bytes === 0 ) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100 } ${ sizes[i]}`; + }, + + /** + * Calculates the total size of files represented by row elements. + * + * @param {Array} rows - Array of row DOM elements with data-size attributes + * @returns {number} Total size in bytes + */ + calculateTotalSize (rows) { + let total = 0; + rows.forEach(row => { + const size = parseInt($(row).attr('data-size')) || 0; + total += size; + }); + return total; + }, + + /** + * Updates the footer status bar with item counts and sizes. + * + * Shows total item count and size, plus selected item count and size if any. + * + * @returns {void} + */ + updateFooterStats () { + const $footer = this.$el_window.find('.files-footer'); + const $selectionActions = this.$el_window.find('.files-selection-actions'); + if ( ! $footer.length ) return; + + const allRows = this.$el_window.find('.files-tab .row').toArray(); + const selectedRows = this.$el_window.find('.files-tab .row.selected').toArray(); + + const totalCount = allRows.length; + const selectedCount = selectedRows.length; + + const totalSize = this.calculateTotalSize(allRows); + const selectedSize = this.calculateTotalSize(selectedRows); + + const itemText = totalCount === 1 ? 'item' : 'items'; + $footer.find('.files-footer-item-count').html( + `${totalCount} ${itemText} · ${window.byte_format(totalSize)}`, + ); + + if ( selectedCount > 0 ) { + const selectedItemText = selectedCount === 1 ? 'item' : 'items'; + $footer.find('.files-footer-selected-items') + .html(`${selectedCount} ${selectedItemText} selected · ${window.byte_format(selectedSize)}`) + .css('display', 'inline'); + $footer.find('.files-footer-separator').css('display', 'inline'); + } else { + $footer.find('.files-footer-selected-items').css('display', 'none'); + $footer.find('.files-footer-separator').css('display', 'none'); + } + + // Show/hide floating action bar based on selection count + // In mobile select mode, show with 1+ items; otherwise require 2+ + const isMobileSelectMode = (window.isMobile.phone || window.isMobile.tablet) && this.selectModeActive; + const minCountForActionBar = isMobileSelectMode ? 1 : 2; + + if ( selectedCount >= minCountForActionBar ) { + $selectionActions.addClass('visible'); + this.updateSelectionActionsState(selectedRows); + } else { + $selectionActions.removeClass('visible'); + } + }, + + /** + * Toggles between list and grid view modes. + * + * Persists the preference to storage. + * + * @returns {void} + */ + toggleView () { + const $filesContainer = this.$el_window.find('.files-tab .files'); + const $toggleBtn = this.$el_window.find('.view-toggle-btn'); + const $tabContent = this.$el_window.find('.files-tab'); + + if ( this.currentView === 'list' ) { + this.currentView = 'grid'; + $filesContainer.removeClass('files-list-view').addClass('files-grid-view'); + $tabContent.addClass('files-grid-mode'); + $toggleBtn.html(icons.list); + $toggleBtn.attr('title', 'Switch to list view'); + } else { + this.currentView = 'list'; + $filesContainer.removeClass('files-grid-view').addClass('files-list-view'); + $tabContent.removeClass('files-grid-mode'); + $toggleBtn.html(icons.grid); + $toggleBtn.attr('title', 'Switch to grid view'); + } + + puter.kv.set('view_mode', this.currentView); + + // Refresh content to update icons for the new view mode + if ( this.currentPath ) { + this.renderDirectory(this.currentPath); + } + }, + + /** + * Toggles select mode for mobile multi-file selection. + * + * When active, tapping files toggles their selection instead of opening them. + * Checkboxes appear next to each item for visual feedback. + * + * @returns {void} + */ + toggleSelectMode () { + this.selectModeActive = !this.selectModeActive; + const $filesTab = this.$el_window.find('.files-tab'); + const $selectBtn = this.$el_window.find('.select-mode-btn'); + + if ( this.selectModeActive ) { + $filesTab.addClass('select-mode-active'); + $selectBtn.addClass('active'); + } else { + $filesTab.removeClass('select-mode-active'); + $selectBtn.removeClass('active'); + // Clear all selections when exiting select mode + this.$el_window.find('.files .row.selected').removeClass('selected'); + this.updateFooterStats(); + } + }, + + /** + * Exits select mode and clears selections. + * + * @returns {void} + */ + exitSelectMode () { + if ( this.selectModeActive ) { + this.selectModeActive = false; + const $filesTab = this.$el_window.find('.files-tab'); + const $selectBtn = this.$el_window.find('.select-mode-btn'); + $filesTab.removeClass('select-mode-active'); + $selectBtn.removeClass('active'); + // Clear all selections + this.$el_window.find('.files .row.selected').removeClass('selected'); + this.updateFooterStats(); + } + }, + + /** + * Navigates back to the original folder after cancelling a spring-loaded drag. + * Walks back through nav history to find the original path position. + * + * @returns {void} + */ + navigateBackFromSpringLoad () { + if ( ! this.springLoadedOriginalPath ) return; + + // Walk back through nav history to find the original path + for ( let i = window.dashboard_nav_history_current_position - 1; i >= 0; i-- ) { + if ( window.dashboard_nav_history[i] === this.springLoadedOriginalPath ) { + window.dashboard_nav_history_current_position = i; + this.renderDirectory(this.springLoadedOriginalPath); + return; + } + } + // Fallback: render the original path directly + this.renderDirectory(this.springLoadedOriginalPath); + }, + + /** + * Initializes the navigation history with a starting path. + * + * @param {string} initialPath - The initial directory path + * @returns {void} + */ + initNavHistory (initialPath) { + window.dashboard_nav_history = [initialPath]; + window.dashboard_nav_history_current_position = 0; + this.updateNavButtonStates(); + }, + + /** + * Pushes a new path onto the navigation history stack. + * + * Truncates any forward history when navigating to a new location. + * + * @param {string} newPath - The path to add to history + * @returns {void} + */ + pushNavHistory (newPath) { + // If history is empty, initialize with this path + if ( window.dashboard_nav_history.length === 0 ) { + window.dashboard_nav_history = [newPath]; + window.dashboard_nav_history_current_position = 0; + } else { + // Truncate forward history when navigating to new location + window.dashboard_nav_history = window.dashboard_nav_history.slice(0, window.dashboard_nav_history_current_position + 1); + window.dashboard_nav_history.push(newPath); + window.dashboard_nav_history_current_position++; + } + this.updateNavButtonStates(); + }, + + /** + * Updates the enabled/disabled state of navigation buttons. + * + * Disables back button at history start, forward button at history end, + * and up button at root directory. + * + * @returns {void} + */ + updateNavButtonStates () { + if ( ! this.$el_window ) return; + + const backBtn = this.$el_window.find('.path-btn-back'); + const forwardBtn = this.$el_window.find('.path-btn-forward'); + const upBtn = this.$el_window.find('.path-btn-up'); + + if ( window.dashboard_nav_history_current_position === 0 ) { + backBtn.addClass('path-btn-disabled'); + } else { + backBtn.removeClass('path-btn-disabled'); + } + + if ( window.dashboard_nav_history_current_position >= window.dashboard_nav_history.length - 1 ) { + forwardBtn.addClass('path-btn-disabled'); + } else { + forwardBtn.removeClass('path-btn-disabled'); + } + + if ( this.currentPath === '/' ) { + upBtn.addClass('path-btn-disabled'); + } else { + upBtn.removeClass('path-btn-disabled'); + } + }, + + /** + * Updates the browser URL hash to reflect the current file path in Dashboard. + * + * @param {string} filePath - The current file system path (e.g., /username/Documents) + * @returns {void} + */ + updateDashboardUrl (filePath) { + // Disabled: don't modify the browser URL when navigating files. + }, + + /** + * Handles click on the "more" button (three dots) for a file row. + * + * Shows appropriate context menu for single or multi-selection. + * + * @param {HTMLElement} rowElement - The row element that was clicked + * @param {Object} file - The file/folder object data + * @returns {Promise} + */ + async handleMoreClick (rowElement, file, targetElement) { + const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + + let items; + if ( selectedRows.length > 1 && rowElement.classList.contains('selected') ) { + items = await this.generateMultiSelectContextMenu(selectedRows); + } + else { + items = await this.generateContextMenuItems(rowElement, file); + } + + // Use mobile-friendly context menu on touch devices + if ( window.isMobile.phone || window.isMobile.tablet || navigator.maxTouchPoints > 0 ) { + const targetRect = targetElement.getBoundingClientRect(); + const modal = new ContextMenuModal(); + modal.show(items, targetRect, { title: file.name }); + } else { + UIContextMenu({ items: items }); + } + }, + + /** + * Generates context menu items for a single file/folder. + * + * @param {HTMLElement} el_item - The row DOM element + * @param {Object} options - The file/folder object with metadata + * @returns {Promise} Array of menu item objects + */ + async generateContextMenuItems (el_item, options) { + const _this = this; + + const is_trash = $(el_item).attr('data-path') === window.trash_path || $(el_item).attr('data-shortcut_to_path') === window.trash_path; + const is_trashed = ($(el_item).attr('data-path') || '').startsWith(`${window.trash_path }/`); + const is_worker = $(el_item).attr('data-is_worker') === "1"; + + const menu_items = await generate_file_context_menu({ + element: el_item, + fsentry: options, + is_trash, + is_trashed, + is_worker, + suggested_apps: options.suggested_apps, + associated_app_name: options.associated_app_name, + onRestore: async (el) => { + await _this.restoreItem(el); + $(el).fadeOut(150, function () { + $(this).remove(); + }); + _this.updateFooterStats(); + }, + onOpen: (el, fsentry) => { + // Custom open handler for Dashboard (avoids window_nav_history issues) + if ( fsentry.is_dir ) { + _this.pushNavHistory(fsentry.path); + _this.renderDirectory(fsentry.path); + } else { + open_item({ item: el }); + } + }, + }); + + return menu_items; + }, + + /** + * Generates context menu items for multiple selected files/folders. + * + * Provides bulk operations like download, cut, copy, and delete. + * + * @param {NodeList|Array} selectedRows - The selected row elements + * @returns {Promise} Array of menu item objects + */ + async generateMultiSelectContextMenu (selectedRows) { + const _this = this; + const items = []; + + // Check if any are trashed + const anyTrashed = Array.from(selectedRows).some(row => { + const path = $(row).attr('data-path'); + return path?.startsWith(`${window.trash_path}/`); + }); + + if ( anyTrashed ) { + items.push({ + html: i18n('restore'), + onClick: async function () { + for ( const row of selectedRows ) { + try { + await _this.restoreItem(row); + $(row).fadeOut(150, function () { + $(this).remove(); + }); + } catch ( err ) { + console.error('Failed to restore item:', err); + } + } + _this.updateFooterStats(); + }, + }); + items.push('-'); + } + + if ( ! anyTrashed ) { + items.push({ + html: `${i18n('download')}`, + onClick: function () { + window.zipItems(Array.from(selectedRows), _this.currentPath, true); + }, + }); + items.push('-'); + } + + // Cut + items.push({ + html: `${i18n('cut')}`, + onClick: function () { + window.clipboard_op = 'move'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ + path: $(row).attr('data-path'), + uid: $(row).attr('data-uid'), + }); + }); + }, + }); + + // Copy + if ( ! anyTrashed ) { + items.push({ + html: `${i18n('copy')}`, + onClick: function () { + window.clipboard_op = 'copy'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ path: $(row).attr('data-path') }); + }); + }, + }); + } + + items.push('-'); + + // Delete + if ( anyTrashed ) { + items.push({ + html: i18n('delete_permanently'), + onClick: async function () { + const confirmed = await UIAlert({ + message: i18n('confirm_delete_multiple_items'), + buttons: [ + { label: i18n('delete'), value: 'delete', type: 'primary' }, + { label: i18n('cancel'), value: 'cancel' }, + ], + }); + if ( confirmed === 'delete' ) { + for ( const row of selectedRows ) { + await window.delete_item(row); + } + } + }, + }); + } + else { + items.push({ + html: `${i18n('delete')}`, + onClick: function () { + window.move_items(Array.from(selectedRows), window.trash_path); + }, + }); + } + + return items; + }, + + /** + * Generates context menu items for folder background (empty area). + * + * Includes options for new folder/file, paste, upload, refresh, etc. + * + * @param {string} [folderPath] - The folder path, defaults to current path + * @returns {Array} Array of menu item objects + */ + generateFolderContextMenu (folderPath) { + const _this = this; + const targetPath = folderPath || this.currentPath; + + if ( ! targetPath ) return []; + + const isTrashFolder = targetPath === window.trash_path; + const items = []; + + // New submenu (folder, text document, etc.) - not available in Trash + // We create a custom "New" submenu to handle folder creation with refresh and rename activation + if ( ! isTrashFolder ) { + const newMenuItems = new_context_menu_item(targetPath, null); + + // Override the "New Folder" onClick to refresh and activate rename + if ( newMenuItems.items && newMenuItems.items.length > 0 ) { + const folderItem = newMenuItems.items[0]; // First item is "New Folder" + folderItem.onClick = async () => { + $('.context-menu').remove(); + _this._creatingItem = true; + try { + const result = await puter.fs.mkdir({ + path: `${targetPath}/New Folder`, + rename: true, + overwrite: false, + }); + // Remove empty-directory placeholder if present + _this.$el_window.find('.files-tab .files > div:not(.item)').remove(); + // Add the new folder incrementally + await _this.renderItem(result); + const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`); + if ( $newRow.length > 0 ) { + _this.insertAtSortedPosition($newRow, result); + _this.applyColumnWidths(); + _this.updateFooterStats(); + $newRow.addClass('selected'); + window.activate_item_name_editor($newRow[0]); + } + } catch ( err ) { + // Folder creation failed silently + } finally { + _this._creatingItem = false; + } + }; + + // Override other file creation items to intercept create_file, + // refresh directory, and activate rename mode + const wrapWithDashboardRename = (originalOnClick) => { + return async () => { + $('.context-menu').remove(); + _this._creatingItem = true; + + // Temporarily intercept create_file to capture the upload promise + let uploadPromise = null; + const origCreateFile = window.create_file; + window.create_file = (options) => { + const content = options.content ? [options.content] : []; + uploadPromise = puter.fs.upload(new File(content, options.name), options.dirname); + return uploadPromise; + }; + + try { + await originalOnClick(); + + // For callback-based creation (e.g., canvas.toBlob), wait briefly + if ( ! uploadPromise ) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + + if ( uploadPromise ) { + const result = await uploadPromise; + // Remove empty-directory placeholder if present + _this.$el_window.find('.files-tab .files > div:not(.item)').remove(); + // Add the new file incrementally + await _this.renderItem(result); + const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`); + if ( $newRow.length > 0 ) { + _this.insertAtSortedPosition($newRow, result); + _this.applyColumnWidths(); + _this.updateFooterStats(); + $newRow.addClass('selected'); + window.activate_item_name_editor($newRow[0]); + } + } + } catch ( err ) { + // File creation failed silently + } finally { + window.create_file = origCreateFile; + _this._creatingItem = false; + } + }; + }; + + for ( let i = 2; i < newMenuItems.items.length; i++ ) { + const item = newMenuItems.items[i]; + if ( !item || typeof item === 'string' ) continue; + if ( item.onClick ) { + item.onClick = wrapWithDashboardRename(item.onClick); + } + // Handle nested submenu items (user templates) + if ( item.items && Array.isArray(item.items) ) { + for ( const subItem of item.items ) { + if ( subItem && subItem.onClick ) { + subItem.onClick = wrapWithDashboardRename(subItem.onClick); + } + } + } + } + } + + items.push(newMenuItems); + items.push('-'); + } + + // Paste - only if clipboard has items and not in Trash + if ( !isTrashFolder && window.clipboard && window.clipboard.length > 0 ) { + items.push({ + html: i18n('paste'), + onClick: async function () { + if ( window.clipboard_op === 'copy' ) { + window.copy_clipboard_items(targetPath, null); + } else if ( window.clipboard_op === 'move' ) { + await _this.moveClipboardItems(targetPath); + // Refresh so moved-away source rows disappear and any + // items pasted into the current folder show up. + _this.renderDirectory(_this.currentPath); + } + }, + }); + } + + // Undo - if there are actions to undo + if ( window.actions_history && window.actions_history.length > 0 ) { + items.push({ + html: i18n('undo'), + onClick: function () { + window.undo_last_action(); + }, + }); + } + + // Add separator if we added paste or undo + if ( items.length > 2 || (isTrashFolder && items.length > 0) ) { + items.push('-'); + } + + // Upload Here - not available in Trash + if ( ! isTrashFolder ) { + items.push({ + html: i18n('upload'), + onClick: function () { + const fileInput = document.querySelector('#upload-file-dialog'); + if ( fileInput ) { + fileInput.click(); + } + }, + }); + } + + // Refresh + items.push({ + html: i18n('refresh'), + onClick: function () { + _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + }, + }); + + // Empty Trash - only in Trash folder + if ( isTrashFolder ) { + items.push('-'); + items.push({ + html: i18n('empty_trash'), + onClick: function () { + window.empty_trash(); + }, + }); + } + + return items; + }, + + /** + * Initializes rubber band (drag-to-select) selection for the files container. + * + * Uses the viselect library to enable drag selection in both list and grid views. + * Only activates when dragging from empty space, not from file/folder items. + * + * @returns {void} + */ + initRubberBandSelection () { + const _this = this; + + // Skip on mobile/touch devices + if ( window.isMobile.phone || window.isMobile.tablet ) { + return; + } + + let selected_ctrl_items = []; + let selection_area = null; + let selection_area_start_x = 0; + let selection_area_start_y = 0; + let initial_container_scroll_width = 0; + let initial_container_scroll_height = 0; + + const filesContainer = this.$el_window.find('.files-tab .files')[0]; + if ( ! filesContainer ) return; + + const containerId = `tabfiles-container-${Date.now()}`; + filesContainer.id = containerId; + + const selection = new SelectionArea({ + selectionContainerClass: 'selection-area-container', + selectionAreaClass: 'hidden-selection-area', + container: `#${containerId}`, + selectables: [`#${containerId} .row`], + startareas: [`#${containerId}`], + boundaries: [`#${containerId}`], + behaviour: { + overlap: 'drop', + intersect: 'touch', + startThreshold: 10, + scrolling: { + speedDivider: 10, + manualSpeed: 750, + startScrollMargins: { x: 0, y: 0 }, + }, + }, + features: { + touch: false, + range: true, + singleTap: { + allow: false, + intersect: 'native', + }, + }, + }); + + this.rubberBandSelection = selection; + + selection.on('beforestart', ({ event }) => { + selected_ctrl_items = []; + + // Block rubberband when starting from an already-selected item + // (so that file dragging can take over instead). + const targetRow = $(event.target).closest('.row:not(.header)'); + if ( targetRow.length && targetRow.hasClass('selected') ) { + return false; + } + + // Block rubberband when starting from item drag handles so item drag takes over + if ( $(event.target).closest('.item-name, .item-icon, .item-badges').length ) { + return false; + } + + // Capture starting position (element created later in 'start' event) + const scrollLeft = $(filesContainer).scrollLeft(); + const scrollTop = $(filesContainer).scrollTop(); + const containerRect = filesContainer.getBoundingClientRect(); + + initial_container_scroll_width = filesContainer.scrollWidth; + initial_container_scroll_height = filesContainer.scrollHeight; + + let relativeX = event.clientX - containerRect.left + scrollLeft; + let relativeY = event.clientY - containerRect.top + scrollTop; + + relativeX = Math.max(0, Math.min(initial_container_scroll_width, relativeX)); + relativeY = Math.max(0, Math.min(initial_container_scroll_height, relativeY)); + + selection_area_start_x = relativeX; + selection_area_start_y = relativeY; + + return true; + }); + + selection.on('start', ({ store, event }) => { + if ( !event.ctrlKey && !event.metaKey ) { + for ( const el of store.stored ) { + el.classList.remove('selected'); + } + selection.clearSelection(); + } + + // Disable pointer events on selection actions bar during drag + _this.$el_window.find('.files-selection-actions').addClass('rubberband-active'); + + // Create selection area element only when drag actually starts (after threshold) + selection_area = document.createElement('div'); + $(filesContainer).append(selection_area); + $(selection_area).addClass('tabfiles-selection-area'); + $(selection_area).css({ + position: 'absolute', + top: selection_area_start_y, + left: selection_area_start_x, + width: 0, + height: 0, + zIndex: 1000, + display: 'block', + }); + }); + + selection.on('move', ({ store: { changed: { added, removed } }, event }) => { + // Skip if no event (can happen during programmatic moves) + if ( ! event ) return; + + const scrollLeft = $(filesContainer).scrollLeft(); + const scrollTop = $(filesContainer).scrollTop(); + const containerRect = filesContainer.getBoundingClientRect(); + + let currentMouseX = event.clientX - containerRect.left + scrollLeft; + let currentMouseY = event.clientY - containerRect.top + scrollTop; + + const constrainedMouseX = Math.max(0, Math.min(filesContainer.scrollWidth, currentMouseX)); + const constrainedMouseY = Math.max(0, Math.min(filesContainer.scrollHeight, currentMouseY)); + + const width = Math.abs(constrainedMouseX - selection_area_start_x); + const height = Math.abs(constrainedMouseY - selection_area_start_y); + const left = Math.min(constrainedMouseX, selection_area_start_x); + const top = Math.min(constrainedMouseY, selection_area_start_y); + + $(selection_area).css({ width, height, left, top }); + + for ( const el of added ) { + if ( (event.ctrlKey || event.metaKey) && $(el).hasClass('selected') ) { + el.classList.remove('selected'); + selected_ctrl_items.push(el); + } else { + el.classList.add('selected'); + window.active_element = el; + window.latest_selected_item = el; + } + } + + for ( const el of removed ) { + el.classList.remove('selected'); + if ( selected_ctrl_items.includes(el) ) { + $(el).addClass('selected'); + } + } + + _this.updateFooterStats(); + }); + + selection.on('stop', () => { + if ( selection_area ) { + $(selection_area).remove(); + selection_area = null; + // Flag to prevent the click handler from clearing selection + _this.rubberBandSelectionJustEnded = true; + } + // Re-enable pointer events on selection actions bar + _this.$el_window.find('.files-selection-actions').removeClass('rubberband-active'); + _this.updateFooterStats(); + }); + }, + + /** + * Initializes native file drag-and-drop upload support. + * + * Sets up dragster on the main files container to allow dropping + * local files for upload. Sidebar folders and folder rows get their + * dragster initialized in init() and createItemListeners() respectively. + * + * @returns {void} + */ + initNativeFileDrop () { + this.initContentAreaDragster(); + }, + + /** + * Initializes dragster on the main files content area. + * + * Dropping files here uploads them to the current directory (this.currentPath). + * Only responds to native file drags (from OS), not internal item drags. + * + * @returns {void} + */ + initContentAreaDragster () { + const _this = this; + const $filesContainer = this.$el_window.find('.files-tab .files'); + + $filesContainer.dragster({ + enter: function (_dragsterEvent, event) { + const e = event.originalEvent; + // Only respond to native file drags, not internal item drags + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + // Don't show drop zone if we're in trash + if ( _this.currentPath === window.trash_path ) { + return; + } + + // Remove any context menus + $('.context-menu').remove(); + + // Add visual drop zone indicator + $filesContainer.addClass('native-drop-active'); + }, + + leave: function (_dragsterEvent, _event) { + $filesContainer.removeClass('native-drop-active'); + }, + + drop: async function (_dragsterEvent, event) { + const e = event.originalEvent; + $filesContainer.removeClass('native-drop-active'); + + // Only handle native file drops + if ( ! e.dataTransfer?.types?.includes('Files') ) { + return; + } + + // Skip if drop was on a subfolder (check if target is inside a folder row) + const $target = $(e.target); + const $folderRow = $target.closest('.row.folder'); + if ( $folderRow.length > 0 ) { + // Drop was on a folder row, let it handle the upload + return; + } + + // Block uploads to trash + if ( _this.currentPath === window.trash_path ) { + return; + } + + // Upload the dropped files + if ( e.dataTransfer?.items?.length > 0 ) { + _this.uploadFiles(e.dataTransfer.items, _this.currentPath); + } + + e.stopPropagation(); + e.preventDefault(); + return false; + }, + }); + }, + + /** + * Uploads files to the specified destination path. + * + * This method handles the complete upload flow including progress modal, + * error handling, and directory refresh on completion. Used by drag-drop + * upload handlers to ensure the Dashboard view updates after uploads. + * + * @param {DataTransferItemList|FileList} items - The files to upload + * @param {string} destPath - The destination directory path + * @returns {void} + */ + uploadFiles (items, destPath) { + const _this = this; + let upload_progress_window; + let opid; + + if ( destPath === window.trash_path ) { + UIAlert('Uploading to trash is not allowed!'); + return; + } + + puter.fs.upload(items, destPath, { + generateThumbnails: true, + init: async (operation_id, xhr) => { + opid = operation_id; + upload_progress_window = await UIWindowProgress({ + title: i18n('upload'), + icon: window.icons['app-icon-uploader.svg'], + operation_id: operation_id, + show_progress: true, + on_cancel: () => { + window.show_save_account_notice_if_needed(); + xhr.abort(); + }, + }); + window.active_uploads[opid] = 0; + }, + start: async function () { + upload_progress_window.set_status('Uploading'); + upload_progress_window.set_progress(0); + }, + progress: async function (_operation_id, op_progress) { + upload_progress_window.set_progress(op_progress); + window.active_uploads[opid] = op_progress; + if ( document.visibilityState !== 'visible' ) { + update_title_based_on_uploads(); + } + }, + success: function (items) { + const files = []; + if ( typeof items[Symbol.iterator] === 'function' ) { + for ( const item of items ) { + files.push(item.path); + } + } else { + files.push(items.path); + } + window.actions_history.push({ + operation: 'upload', + data: files, + }); + setTimeout(() => { + upload_progress_window.close(); + }, 1000); + window.show_save_account_notice_if_needed(); + delete window.active_uploads[opid]; + // Refresh directory to show uploaded files + _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + }, + error: async function (err) { + const failedItems = Array.isArray(err?.failedItems) ? err.failedItems : []; + if ( failedItems.length > 0 ) { + const failedSummary = failedItems.map((item) => { + const failedPath = item?.path || item?.name || `item ${item?.requestIndex ?? '?'}`; + const failedMessage = typeof item?.message === 'string' && item.message.length > 0 + ? ` (${item.message})` + : ''; + return `- ${failedPath}${failedMessage}`; + }).join('\n'); + UIAlert(`Some uploads failed:\n${failedSummary}`); + } + upload_progress_window.show_error(i18n('error_uploading_files'), err.message); + delete window.active_uploads[opid]; + _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + }, + abort: async function (_operation_id) { + delete window.active_uploads[opid]; + }, + }); + }, + + /** + * Renders the breadcrumb path navigation HTML. + * + * Creates clickable path segments with separators. + * + * @param {string} abs_path - The absolute path to render + * @returns {string} HTML string for the breadcrumb navigation + */ + renderPath (abs_path) { + const { html_encode } = window; + // remove trailing slash + if ( abs_path.endsWith('/') && abs_path !== '/' ) { + abs_path = abs_path.slice(0, -1); + } + + const dirs = (abs_path === '/' ? [''] : abs_path.split('/')); + const dirpaths = (abs_path === '/' ? ['/'] : []); + const path_seperator_html = ``; + if ( dirs.length > 1 ) { + for ( let i = 0; i < dirs.length; i++ ) { + dirpaths[i] = ''; + for ( let j = 1; j <= i; j++ ) { + dirpaths[i] += `/${dirs[j]}`; + } + } + } + let str = `${path_seperator_html}${html_encode(window.root_dirname)}`; + for ( let k = 1; k < dirs.length; k++ ) { + str += `${path_seperator_html}${dirs[k] === 'Trash' ? i18n('trash') : html_encode(dirs[k])}`; + } + return str; + }, + + /** + * + * Shows loading spinner over files section + */ + showSpinner () { + if ( this.loading ) return; + this.loading = true; + + const overlay = document.createElement('div'); + overlay.classList.add('files-loading-overlay'); + overlay.innerHTML = ` +
+
+
Working...
+
+ `; + + document.querySelector('.directory-contents .files').appendChild(overlay); + setTimeout(() => { + overlay.style.opacity = 1; + }, 100); + }, + + /** + * + * Hides the loading spinner over files section + */ + hideSpinner () { + const overlay = document.querySelector('.files-loading-overlay'); + if ( overlay ) { + overlay.parentNode?.removeChild(overlay); + } + this.loading = false; + }, +}; + +// Canvas context for measuring text width (reused for performance) +let measureContext = null; + +/** + * Measures the pixel width of text using a canvas context. + * + * @param {string} text - The text to measure + * @param {string} font - CSS font string (e.g., '500 13px system-ui') + * @returns {number} Width in pixels + */ +function measureTextWidth (text, font = '500 13px system-ui, -apple-system, sans-serif') { + if ( ! measureContext ) { + const canvas = document.createElement('canvas'); + measureContext = canvas.getContext('2d'); + } + measureContext.font = font; + return measureContext.measureText(text).width; +} + +/** + * Truncates a filename in the middle to fit a given pixel width, preserving the extension. + * + * @param {string} filename - The full filename to truncate + * @param {number} maxWidth - Maximum width in pixels + * @param {string} font - CSS font string for measurement + * @returns {string} Truncated filename with ellipsis in middle, or original if it fits + */ +function truncateFilenameToWidth (filename, maxWidth, font = '500 13px system-ui, -apple-system, sans-serif') { + const fullWidth = measureTextWidth(filename, font); + if ( fullWidth <= maxWidth ) { + return filename; + } + + // Find extension + const lastDot = filename.lastIndexOf('.'); + const hasExtension = lastDot > 0 && lastDot < filename.length - 1; + const extension = hasExtension ? filename.slice(lastDot) : ''; + const baseName = hasExtension ? filename.slice(0, lastDot) : filename; + + const ellipsis = '…'; + const ellipsisWidth = measureTextWidth(ellipsis, font); + const extensionWidth = measureTextWidth(extension, font); + + // Available width for the base name (before and after ellipsis) + const availableWidth = maxWidth - ellipsisWidth - extensionWidth; + if ( availableWidth <= 0 ) { + return ellipsis + extension; + } + + // Binary search to find how many characters fit + // We want roughly equal parts before and after the ellipsis + const targetHalfWidth = availableWidth / 2; + + let startChars = 0; + let endChars = 0; + + // Find characters for start + for ( let i = 1; i <= baseName.length; i++ ) { + if ( measureTextWidth(baseName.slice(0, i), font) > targetHalfWidth ) { + startChars = i - 1; + break; + } + startChars = i; + } + + // Find characters for end (before extension) + for ( let i = 1; i <= baseName.length - startChars; i++ ) { + if ( measureTextWidth(baseName.slice(-i), font) > targetHalfWidth ) { + endChars = i - 1; + break; + } + endChars = i; + } + + if ( startChars === 0 && endChars === 0 ) { + return ellipsis + extension; + } + + const start = baseName.slice(0, startChars); + const end = endChars > 0 ? baseName.slice(-endChars) : ''; + + return start + ellipsis + end + extension; +} + +export default TabFiles; diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index e3c410603..9ef50258e 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -44,6 +44,7 @@ import UIWindowFeedback from '../UIWindowFeedback.js'; // Import tab modules import TabHome from './TabHome.js'; +import TabFiles from './TabFiles.js'; import TabApps from './TabApps.js'; import TabUsage from './TabUsage.js'; import TabAccount from './TabAccount.js'; @@ -53,6 +54,7 @@ import TabSecurity from './TabSecurity.js'; const builtinTabs = [ TabHome, TabApps, + TabFiles, '-', TabUsage, TabAccount, @@ -165,6 +167,11 @@ async function UIDashboard (options) { $el_window.find('.dashboard-sidebar').addClass('collapsed'); } + // Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it) + if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) { + window.dashboard_initial_file_path = window.dashboard_initial_route.path; + } + // Initialize all tabs for ( const tab of tabs ) { if ( tab.init ) { @@ -236,6 +243,106 @@ async function UIDashboard (options) { } }); + // Trash status updates + window.socket.on('trash.is_empty', async (msg) => { + // Update sidebar Trash icon + const trashIcon = msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg']; + $('.directories [data-folder=\'Trash\'] img').attr('src', trashIcon); + + // If currently viewing trash and it's empty, clear the file list + const dashboard = window.dashboard_object; + if ( msg.is_empty && dashboard && dashboard.currentPath === window.trash_path ) { + $('.files-tab .files').empty(); + } + }); + + // ========================================================================= + // Item event handlers + // Incremental DOM updates using UIDashboardFileItem for item creation and + // direct jQuery manipulation for removals/updates. Mirrors UIDesktop's + // approach but adapted for Dashboard's list-view structure. + // ========================================================================= + + window.socket.on('item.moved', async (resp) => { + if ( resp.original_client_socket_id === window.socket.id ) return; + + // Fade out the stale row at the item's OLD location only. A moved item + // keeps its uid, so removing by uid would also match the row at the new + // location — and when the destination is the directory currently in + // view (e.g. after spring-loading a folder open mid-drag), that deletes + // the freshly-added row instead of the stale one. + const old_path = resp.old_path ?? resp.from_path; + if ( old_path ) { + $(`.item[data-path='${html_encode(old_path)}']`).fadeOut(150, function () { + $(this).remove(); + }); + } + + // Create new item at destination if user is viewing that directory + if ( window.UIDashboardFileItem ) { + window.UIDashboardFileItem(resp); + } + }); + + window.socket.on('item.removed', async (item) => { + if ( item.original_client_socket_id === window.socket.id ) return; + if ( item.descendants_only ) return; + + $(`.item[data-path='${html_encode(item.path)}']`).fadeOut(150, function () { + $(this).remove(); + }); + }); + + window.socket.on('item.renamed', async (item) => { + if ( item.original_client_socket_id === window.socket.id ) return; + + const $el = $(`.item[data-uid='${item.uid}']`); + if ( $el.length === 0 ) return; + + // Update data attributes + $el.attr('data-name', html_encode(item.name)); + $el.attr('data-path', html_encode(item.path)); + + // Update displayed name + $el.find('.item-name').text(item.name); + $el.find('.item-name-editor').val(item.name); + }); + + window.socket.on('item.updated', async (item) => { + if ( item.original_client_socket_id === window.socket.id ) return; + + const $el = $(`.item[data-uid='${item.uid}']`); + if ( $el.length === 0 ) return; + + // Update data attributes + $el.attr('data-name', html_encode(item.name)); + $el.attr('data-path', html_encode(item.path)); + $el.attr('data-size', item.size); + $el.attr('data-modified', item.modified); + $el.attr('data-type', html_encode(item.type)); + + // Update displayed name + $el.find('.item-name').text(item.name); + $el.find('.item-name-editor').val(item.name); + + if ( + window.dashboard_object?.currentView === 'grid' + && typeof item.thumbnail === 'string' + && item.thumbnail.length > 0 + ) { + $el.find('.item-icon img').attr('src', item.thumbnail); + } + }); + + window.socket.on('item.added', async (item) => { + if ( !item || Object.keys(item).length === 0 ) return; + if ( item.original_client_socket_id === window.socket.id ) return; + + if ( window.UIDashboardFileItem ) { + window.UIDashboardFileItem(item); + } + }); + // Apply initial route from URL - activate the correct tab if ( window.dashboard_initial_route ) { const route = window.dashboard_initial_route; @@ -269,6 +376,7 @@ async function UIDashboard (options) { const handleRouteChange = () => { const route = window.parseDashboardRoute(); const tab = route.tab; + const filePath = route.path; // Switch to correct tab const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`); @@ -290,6 +398,14 @@ async function UIDashboard (options) { // Scroll content area to top $el_window.find('.dashboard-content').scrollTop(0); + + // If files tab with path, navigate without adding to history + if ( tab === 'files' && filePath ) { + const filesTab = tabs.find(t => t.id === 'files'); + if ( filesTab?.renderDirectory ) { + filesTab.renderDirectory(filePath, { skipUrlUpdate: true, skipNavHistory: true }); + } + } }; // Listen for both hashchange and popstate to handle all navigation scenarios diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 26e4868b3..96a05654f 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -1047,6 +1047,1078 @@ input.myapps-search::-webkit-search-decoration { opacity: 0.9; } +/* Dashboard files */ + +.dashboard-content.files { + padding: 0 0 0 10px; + overflow: hidden; +} + +.dashboard-tab-content.files-tab { + display: flex; + justify-content: flex-start; + align-items: flex-start; + max-width: unset; + padding: 0; + margin: 0; +} + +.dashboard-tab-content.files-tab h2 { + margin: 0 0 6px 0; + font-size: 26px; + font-weight: 700; + color: var(--dashboard-text-primary); + letter-spacing: -0.02em; +} + +.dashboard-section-files .directories { + position: sticky; + top: 0; + width: 160px; + padding: 16px 0; +} + +.dashboard-section-files .directories ul { + list-style: none; + padding: 0; + margin: 0; +} + +.dashboard-section-files .directories li { + display: flex; + align-items: center; + gap: 10px; + margin-top: 3px; + margin-right: 10px; + padding: 3px 0px 3px 5px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: var(--dashboard-text); + border: 2px dashed transparent; + transition: background-color 0.15s; +} + +@media (hover: hover) { + .dashboard-section-files .directories li:hover { + background: var(--dashboard-hover); + } +} + +.dashboard-section-files .directories li.context-menu-active { + background: var(--dashboard-hover); +} + +.dashboard-section-files .directories li.active { + color: var(--select-color); + font-weight: 500; +} + +.dashboard-section-files .directories li img { + width: 28px; + height: auto; +} + +.dashboard-section-files .directories li[data-folder="Trash"] { + position: fixed; + bottom: 0; + margin-bottom: 20px; + width: 150px; + height: 38px; +} + +.dashboard-section-files .directory-contents { + position: relative; + width: calc(100% - 160px); + min-height: 100vh; + border-left: 1px solid var(--dashboard-border); +} + +.dashboard-section-files .directory-contents .header { + position: sticky; + top: 0; + padding-top: 12px; + background: var(--dashboard-background); +} + +.dashboard-section-files .header .path { + font-size: 14px; + height: 47px; + display: flex; + align-items: center; + justify-content: flex-start; + padding: 10px 12px; + background: linear-gradient(0deg, var(--dashboard-sidebar-background), var(--dashboard-background)); + border-bottom: 1px solid var(--dashboard-border); +} + +.dashboard .path-nav-buttons { + padding: 4px 10px 4px 0px; + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-right: 5px; + border-right: 1px dotted var(--dashboard-border); +} + +.dashboard .path-btn { + opacity: 0.6; + width: 28px; + cursor: pointer; + border-radius: 5px; + padding: 4px; + background-color: transparent; +} + +@media (hover: hover) { + .dashboard .path-btn:hover { + filter: invert(1) hue-rotate(180deg); + background-color: var(--select-color); + opacity: 1; + } +} + +@media (prefers-color-scheme: dark) { + .path-btn { + filter: invert(1) hue-rotate(180deg); + } + @media (hover: hover) { + .path-btn:hover { + filter: invert(0) hue-rotate(0deg); + background-color: var(--select-color); + opacity: 1; + } + } +} + +.dashboard-section-files .header .path-breadcrumbs { + display: flex; + align-items: center; + margin-left: 10px; +} + +.dashboard-section-files .header .path-breadcrumbs:empty + .path-actions { + display: none; +} + +.dashboard-section-files .header .path-actions { + display: flex; + gap: 10px; + margin-left: auto; +} + +.dashboard-section-files .header .path-action-btn { + background-color: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--dashboard-icon); + display: flex; + align-items: center; + justify-content: center; +} + +@media (hover: hover) { + .dashboard-section-files .header .path-action-btn:hover { + color: var(--dashboard-background); + background-color: var(--select-color); + } +} + +.dashboard-section-files .header .path-btn-disabled { + opacity: 0.1; + pointer-events: none; +} + +.dashboard-section-files .header .path-action-btn svg { + width: 24px; + height: 24px; +} + +.dashboard-section-files .header .path .dirname { + height: auto; + font-weight: 400; + -webkit-font-smoothing: subpixel-antialiased; + color: var(--dashboard-text-secondary); + cursor: pointer; + background-color: transparent; + padding: 3px 6px; + font-size: 13px; + border: 1px solid transparent; + border-radius: 6px; +} + +@media (hover: hover) { + .dashboard-section-files .header .path .dirname:hover { + color: var(--dashboard-background); + background-color: var(--select-color); + border: 1px solid var(--select-color); + } +} + +.dashboard-section-files .header .path .dirname.drop-target { + color: var(--dashboard-text); + background-color: rgba(59, 130, 246, 0.15); + border: 1px dashed var(--select-color); +} + +.dashboard-section-files .header .path .dirname.context-menu-active { + color: var(--dashboard-background); + background-color: var(--select-color); + border: 1px solid var(--select-color); +} + +.dashboard-section-files .header .columns { + display: grid; + height: 32px; + padding: 0 10px; + grid-template-columns: 24px auto 4px 100px 4px 120px 4px 20px; + align-items: center; + margin: 1px 2px; + color: var(--dashboard-text-secondary); + border-bottom: 1px solid var(--dashboard-border); + font-size: 12px; +} + +.dashboard-section-files .files { + width: 100%; + height: calc(100vh - 124px); + display: flex; + flex-direction: column; + padding-bottom: 30px; + overflow-y: auto; + touch-action: pan-y; +} + +.dashboard-section-files .row { + display: grid; + width: unset; + height: 32px; + padding: 0 10px; + grid-template-columns: 24px auto 4px 100px 4px 120px 4px 20px; + align-items: center; + font-size: 13px; + color: var(--dashboard-text); + touch-action: pan-y; + margin: 1px 2px; + pointer-events: auto; + float: unset; + -webkit-user-select: none; + user-select: none; +} + +.dashboard-section-files .row.folder { + border: 1px solid transparent; +} + +@media (hover: hover) { + .dashboard-section-files .row:hover { + background: var(--dashboard-hover); + border-radius: 3px; + } +} + +.dashboard-section-files .row.selected { + color: var(--primary-color-sidebar-item); + background-color: var(--select-color); + border-radius: 3px; +} + +@keyframes item-added-highlight { + from { background-color: var(--select-color); } + to { background-color: transparent; } +} + +.dashboard-section-files .row.item-newly-added { + animation: item-added-highlight 2s ease-out; +} + +.dashboard-section-files .row img { + width: 18px; + height: 18px; +} + +.dashboard-section-files .row .item-icon, +.dashboard-section-files .header .columns .item-icon { + padding: inherit; + height: 100%; + width: 100%; + filter: none; + margin: 0; +} + +.dashboard-section-files .row .item-name-wrapper { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} + +.dashboard-section-files .row .item-name, +.dashboard-section-files .header .columns .item-name { + /* text-overflow: ellipsis; */ + white-space: nowrap; + overflow: hidden; + max-width: unset; + color: currentColor !important; + text-shadow: none; + padding: 0 8px; + margin: 0; + font-size: inherit; + font-weight: 500; + word-break: inherit; + line-height: 32px; +} + +/* On touch devices, item-name/icon/badges must not be direct event targets + so that pointerdown's isDragHandle check can't match them and accidentally + select items when the user is scrolling. Events pass through to the .row. */ +.files-tab.touch-device .row:not(.header) .item-name, +.files-tab.touch-device .row:not(.header) .item-icon, +.files-tab.touch-device .row:not(.header) .item-badges { + pointer-events: none; +} + +.dashboard-section-files .row textarea { + align-items: center; + width: 100%; + height: 20px; + margin: 0; + padding: 3px 8px 0 8px; + text-align: left; + font-weight: inherit; + display: none; + white-space: nowrap; +} + +.dashboard-section-files .row .item-size { + white-space: nowrap; + overflow: hidden; + line-height: 32px; + text-align: left; +} + +.dashboard-section-files .row .item-modified { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + line-height: 32px; + text-align: left; +} + +.dashboard-section-files .row .item-more { + color: var(--dashboard-border); + cursor: pointer; +} + +.dashboard-section-files .row .item-more svg { + pointer-events: none; +} + +@media (hover: hover) { + .dashboard-section-files .row:hover .item-more { + color: var(--dashboard-text); + } +} + +.dashboard-section-files.ui-draggable-dragging { + background-color: transparent !important; + opacity: 1 !important; +} + +/* --- List view drag ghost --- */ + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row, +.dashboard-section-files.item-selected-clone .files-list-view .row { + background-color: var(--select-color) !important; + color: var(--dashboard-background) !important; + border-radius: 3px; + cursor: move; + width: auto !important; + display: grid !important; + grid-template-columns: 24px auto !important; + align-items: center; + height: 32px; +} + +.dashboard-section-files.item-selected-clone .files-list-view .row { + opacity: 0.6; + pointer-events: none; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-name-wrapper, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-name-wrapper { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-icon, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-icon { + width: 24px; + height: 24px; + padding: 0; + background: white; + border-radius: 2px; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-icon img, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-icon img { + width: 18px; + height: 18px; + object-fit: cover; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-name, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-name { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: unset; + color: currentColor; + text-shadow: none; + padding: 0 8px; + margin: 0; + font-size: 12px; + font-weight: 500; + word-break: inherit; + line-height: 32px; +} + +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-metadata, +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .col-spacer, +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-more, +.dashboard-section-files.ui-draggable-dragging .files-list-view .row .item-name-editor, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-metadata, +.dashboard-section-files.item-selected-clone .files-list-view .row .col-spacer, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-more, +.dashboard-section-files.item-selected-clone .files-list-view .row .item-name-editor { + display: none !important; +} + +/* --- Grid view drag ghost --- */ + +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row, +.dashboard-section-files.item-selected-clone .files-grid-view .row { + background-color: var(--select-color) !important; + color: var(--dashboard-background) !important; + cursor: move; +} + +.dashboard-section-files.item-selected-clone .files-grid-view .row { + opacity: 0.6; + pointer-events: none; +} + +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-name-wrapper, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-name-wrapper { + /* display: none !important; */ +} + +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-metadata, +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .col-spacer, +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-more, +.dashboard-section-files.ui-draggable-dragging .files-grid-view .row .item-name-editor, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-metadata, +.dashboard-section-files.item-selected-clone .files-grid-view .row .col-spacer, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-more, +.dashboard-section-files.item-selected-clone .files-grid-view .row .item-name-editor { + display: none !important; +} + +.dashboard-section-files .row.folder.ui-droppable-hover, +.dashboard-section-files .row.folder.selected.ui-droppable-over { + color: var(--dashboard-text); + background-color: rgba(59, 130, 246, 0.1); + border: 2px dashed var(--select-color); + border-radius: 3px; +} + +/* Spring-loaded folder dwell animation */ +.dashboard-section-files .row.folder.dwell-opening, +.dashboard-section-files .directories li.dwell-opening { + background: linear-gradient(90deg, rgba(59, 130, 246, 0.15) 100%, transparent 100%); + background-size: 0% 100%; + background-repeat: no-repeat; + border: 2px dashed var(--select-color); + border-radius: 3px; + animation: dwell-fill 700ms linear forwards; +} + +@keyframes dwell-fill { + from { background-size: 0% 100%; } + to { background-size: 100% 100%; } +} + +.dashboard-section-files .draggable-count-badge { + position: fixed; + background: var(--select-color); + color: var(--dashboard-background); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + pointer-events: none; + z-index: 10001; +} + +/* Drag cancel zone — shown after spring-loaded folder navigation */ +.drag-cancel-zone { + position: absolute; + bottom: 32px; + right: 32px; + background: #ef4444; + color: white; + padding: 16px 32px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 10001; + user-select: none; + cursor: default; + transition: background 0.15s, transform 0.15s; +} + +.drag-cancel-zone.drag-cancel-hover { + background: #dc2626; + transform: scale(1.05); +} + +.dashboard-section-files .directories li.ui-droppable-hover.active { + background-color: rgba(59, 130, 246, 0.1); + border: 2px dashed var(--select-color); + border-radius: 4px; +} + +/* Native file drop visual feedback for Dashboard */ +.dashboard-section-files .files.native-drop-active { + background-color: rgba(0, 120, 212, 0.08); + outline: 2px dashed #0078d4; + outline-offset: -2px; + border-radius: 4px; +} + +.dashboard-section-files .directories li.native-drop-target { + background-color: rgba(0, 120, 212, 0.15); + border-radius: 4px; +} + +.dashboard-section-files .files .row.folder.native-drop-target { + background-color: rgba(0, 120, 212, 0.15); +} + +/* Dark mode support for native file drop */ +.window[data-color-scheme="dark"] .dashboard-section-files .files.native-drop-active { + background-color: rgba(100, 180, 255, 0.12); + outline-color: #4da3ff; +} + +.window[data-color-scheme="dark"] .dashboard-section-files .directories li.native-drop-target, +.window[data-color-scheme="dark"] .dashboard-section-files .files .row.folder.native-drop-target { + background-color: rgba(100, 180, 255, 0.2); +} + +.dashboard-section-files .files-footer { + position: absolute; + bottom: 0; + right: 0; + left: 0; + background: linear-gradient(180deg, var(--dashboard-sidebar-background), var(--dashboard-background)); + border-top: 1px solid var(--dashboard-border); + height: 30px; + font-size: 13px; + line-height: 28px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #666; + z-index: 10; +} + +.dashboard-section-files .files-footer-separator, +.dashboard-section-files .files-footer-selected-items { + display: none; +} + +.dashboard-section-files .files-footer-separator { + color: #CCC; +} + +/* Floating Selection Actions Bar */ +.dashboard-section-files .files-selection-actions { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%) translateY(100px); + background: var(--dashboard-card-background); + border: 1px solid var(--dashboard-border); + border-radius: 12px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 4px; + box-shadow: 0 4px 20px var(--dashboard-shadow-medium), + 0 2px 8px var(--dashboard-shadow-light); + z-index: 15; + opacity: 0; + visibility: hidden; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.25s ease, + visibility 0.25s ease; +} + +.dashboard-section-files .files-selection-actions.visible { + transform: translateX(-50%) translateY(0); + opacity: 1; + visibility: visible; + z-index: 99999999999999999; +} + +.dashboard-section-files .files-selection-actions.rubberband-active { + pointer-events: none; +} + +.dashboard-section-files .selection-action-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dashboard-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +@media (hover: hover) { + .dashboard-section-files .selection-action-btn:hover { + background: var(--dashboard-hover); + } +} + +.dashboard-section-files .selection-action-btn:active { + transform: scale(0.97); +} + +.dashboard-section-files .selection-action-btn svg { + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.dashboard-section-files .selection-action-btn.restore-btn { + color: #43a047; +} + +@media (hover: hover) { + .dashboard-section-files .selection-action-btn.restore-btn:hover { + background: rgba(67, 160, 71, 0.1); + } +} + +.dashboard-section-files .selection-action-btn.delete-btn { + color: #e53935; +} + +@media (hover: hover) { + .dashboard-section-files .selection-action-btn.delete-btn:hover { + background: rgba(229, 57, 53, 0.1); + } +} + +/* Select mode button - hidden on desktop by default */ +.dashboard-section-files .header .path-action-btn.select-mode-btn { + display: none; +} + +/* Done button in floating action bar - hidden by default, shown in select mode on mobile */ +.dashboard-section-files .files-selection-actions .done-btn { + display: none; +} + +/* Checkbox in item rows - hidden by default */ +.dashboard-section-files .files-tab .files .row .item-checkbox { + display: none; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dashboard-section-files .files-tab .files .row .item-checkbox .checkbox-icon { + width: 20px; + height: 20px; + border: 2px solid var(--dashboard-border); + border-radius: 4px; + background: var(--dashboard-card-background); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.dashboard-section-files .files-tab .files .row.selected .item-checkbox .checkbox-icon { + background: var(--primary-color, #3b82f6); + border-color: var(--primary-color, #3b82f6); +} + +.dashboard-section-files .files-tab .files .row.selected .item-checkbox .checkbox-icon::after { + content: ''; + width: 6px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-bottom: 2px; +} + +.dashboard-section-files .files-tab .files.files-list-view .row { + display: grid; + grid-template-columns: 24px auto 4px 100px 4px 120px 4px 20px; + height: 32px; + padding: 0 10px; + align-items: center; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-icon { + position: relative; + width: 24px; + height: 24px; + padding: 0; + background: var(--dashboard-background); + border-radius: 2px; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-icon img { + width: 18px; + height: 18px; + object-fit: cover; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-size, +.dashboard-section-files .files-tab .files.files-list-view .row .item-modified, +.dashboard-section-files .files-tab .files.files-list-view .row .item-more { + display: block; + padding: 0 10px; +} + +.dashboard-section-files .files-tab .files.files-grid-view { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 10px; + padding: 16px; + align-content: start; + margin-bottom: 30px; +} + +.dashboard-section-files .files-tab .files.files-list-view .row .item-badges { + width: 36px; + height: 36px; + top: 0; + left: 0; + right: 0; + bottom: 0; + justify-content: flex-end; + align-items: flex-start; +} + +.dashboard-section-files .files-tab .files.files-list-view .row img.item-badge { + width: 12px !important; + height: 12px !important; + margin: 0 -2px; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-badges { + width: 100%; + height: 100%; + top: 0; + left: 0; + right: 0; + bottom: 0; + justify-content: flex-end; + align-items: flex-start; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row img.item-badge { + width: 20px !important; + height: 20px !important; + margin: 5px; + border-radius: 50%; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + padding: 12px; + height: auto; + gap: 8px; + border: 1px solid var(--dashboard-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); +} + +@media (hover: hover) { + .dashboard-section-files .files-tab .files.files-grid-view .row:hover { + background: var(--dashboard-sidebar-background); + border-color: var(--dashboard-border); + } +} + +.dashboard-section-files .files-tab .files.files-grid-view .row.selected { + background-color: var(--select-color); + color: var(--dashboard-background); + border-color: var(--select-color); +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon { + width: 150px; + height: 150px; + display: flex; + align-items: center; + justify-content: center; + /* background: #fafafa; */ + border-radius: 8px; + overflow: hidden; + background: white; + border-radius: 2px; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon img { + width: 100%; + height: 100%; + object-fit: contain; + max-width: fit-content; + max-height: fit-content; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon svg { + width: 64px; + height: 64px; + opacity: 0.5; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-name { + text-align: center; + width: 100%; + padding: 0; + font-size: 13px; + line-height: 1.4; + max-height: 2.8em; + overflow: hidden; + word-break: break-word; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-size, +.dashboard-section-files .files-tab .files.files-grid-view .row .item-modified, +.dashboard-section-files .files-tab .files.files-grid-view .row .item-more, +.dashboard-section-files .files-tab .files.files-grid-view .row .col-spacer { + display: none; +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-name-wrapper { + width: 100%; + display: block; +} + +@media (hover: hover) { + .dashboard-section-files .files-tab .files.files-grid-view .row:hover .item-more { + position: absolute; + top: 1px; + right: 1px; + color: #666; + background: var(--dashboard-sidebar-background); + width: 24px; + height: 24px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 7px; + } +} + +/* Hide .item-more on desktop (non-touch devices) - use right-click context menu instead */ +.dashboard-section-files .files-tab:not(.touch-device) .files.files-list-view .row .item-more, +.dashboard-section-files .files-tab:not(.touch-device) .files.files-grid-view .row .item-more, +@media (hover: hover) { + .dashboard-section-files .files-tab:not(.touch-device) .files.files-grid-view .row:hover .item-more, + .dashboard-section-files .files-tab:not(.touch-device) .header .columns .item-more { + display: none !important; + } +} + +.dashboard-section-files .files-tab .files.files-grid-view .row .item-name-editor { + text-align: center; +} + +.dashboard-section-files .files-tab.files-grid-mode .header .columns { + display: none; +} + +/* Sortable column headers */ +.dashboard-section-files .header .columns .sortable { + cursor: pointer; + user-select: none; + position: relative; + display: flex; + align-items: center; + gap: 4px; + padding: 0 10px; + justify-content: space-between; +} + +@media (hover: hover) { + .dashboard-section-files .header .columns .sortable:hover { + color: var(--dashboard-text-heading); + } +} + +.dashboard-section-files .header .columns .sortable::after { + content: ''; + display: inline-block; + width: 0; + height: 0; + margin-left: 4px; + opacity: 0.3; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid currentColor; +} + +.dashboard-section-files .header .columns .sortable.sort-asc::after { + opacity: 1; + border-bottom: 5px solid currentColor; + border-top: none; +} + +.dashboard-section-files .header .columns .sortable.sort-desc::after { + opacity: 1; + border-top: 5px solid currentColor; + border-bottom: none; +} + +/* Column resize handles */ +.dashboard-section-files .header .columns .col-resize-handle { + width: 4px; + height: 100%; + cursor: col-resize; + background: transparent; + position: relative; + background: var(--dashboard-sidebar-background); +} + +@media (hover: hover) { + .dashboard-section-files .header .columns .col-resize-handle:hover { + background: var(--dashboard-border); + } +} + +.dashboard-section-files .header .columns .col-resize-handle:active { + background: var(--select-color); +} + +.dashboard-section-files .more-btn { + background: none; + border: none; + padding: 6px; + cursor: pointer; + color: var(--text-muted); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all 0.15s ease; + position: relative; +} + +.dashboard-section-files .more-menu { + position: absolute; + min-width: 180px; + background: var(--dashboard-background); + border: 1px solid var(--dashboard-border); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + z-index: 1000; + padding: 4px; +} + +.dashboard-section-files .more-menu .menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + background: none; + border: none; + border-radius: 6px; + font-size: 0.85rem; + color: var(--dashboard-text); + cursor: pointer; + transition: background 0.15s ease; + text-align: left; +} + +@media (hover: hover) { + .dashboard-section-files .more-menu .menu-item:hover { + background: var(--dashboard-hover); + } +} + +.dashboard-section-files .more-menu .menu-item svg { + width: 16px; + height: 16px; + color: var(--dashboard-border); + flex-shrink: 0; +} + +.dashboard-section-files .more-menu .menu-item.has-submenu { + position: relative; +} + +.dashboard-section-files .more-menu .menu-item.has-submenu svg:last-child { + margin-left: auto; + width: 0.85rem; + height: 0.85rem; +} + +.dashboard-section-files .more-menu .menu-item.danger { + color: #ea4335; +} + +.dashboard-section-files .more-menu .menu-item.danger svg { + color: #ea4335; +} + +@media (hover: hover) { + .dashboard-section-files .more-menu .menu-item.danger:hover { + background: rgba(234, 67, 53, 0.1); + } +} + +.dashboard-section-files .more-menu .menu-divider { + height: 1px; + background: var(--dashboard-border); + margin: 4px 0; +} + /* Mobile sidebar toggle */ .dashboard-sidebar-toggle { display: none; @@ -1177,6 +2249,270 @@ input.myapps-search::-webkit-search-decoration { } } +/* Desktop: Make metadata wrapper transparent */ +.dashboard-section-files .files-tab .files.files-list-view .row .item-metadata { + display: contents; +} + +/* Image preview popover */ +.image-preview-popover { + position: fixed; + z-index: 9999; + background: var(--dashboard-background); + border: 1px solid var(--dashboard-border); + border-radius: 8px; + padding: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.image-preview-popover img { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + border-radius: 4px; +} + +.image-preview-name { + font-size: 14px; + color: var(--dashboard-text); + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Mobile phone optimizations */ +@media (max-width: 480px) { + .dashboard-content.files { + padding: 0; + } + + /* Hide directories sidebar */ + .dashboard-section-files .directories { + display: none; + } + + /* Full width for directory contents */ + .dashboard-section-files .directory-contents { + width: 100%; + border-left: none; + } + + .dashboard-section-files .directory-contents .header { + margin-bottom: 12px; + } + + /* Hide column headers */ + .dashboard-section-files .header .columns { + display: none; + } + + /* Two-row header layout */ + .dashboard-section-files .header .path { + flex-wrap: wrap; + height: auto; + padding: 8px 12px; + } + + .dashboard-section-files .header .path-breadcrumbs { + order: 1; + width: 100%; + margin: 0 0 8px 0; + padding-bottom: 8px; + margin-left: 50px; + flex-wrap: nowrap; + white-space: nowrap; + overflow-x: auto; + border-bottom: 1px solid var(--dashboard-border); + } + + .dashboard-section-files .header .path-nav-buttons { + order: 2; + border-right: none; + margin-right: 0; + } + + .dashboard-section-files .header .path-actions { + order: 3; + margin-left: auto; + } + + /* Two-row item layout */ + .dashboard-section-files .files-tab .files.files-list-view .row { + display: grid; + grid-template-columns: 48px 1fr !important; + grid-template-rows: auto auto; + height: auto; + padding: 6px 10px; + gap: 2px 8px; + } + + /* Thumbnail spans both rows */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-icon { + grid-row: 1 / 3; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-icon img { + width: 40px; + height: 40px; + } + + /* File name on first row */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-name-wrapper { + grid-column: 2; + grid-row: 1; + padding-right: 40px; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-name { + padding: 0; + line-height: 24px; + } + + /* Metadata wrapper for second row */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-metadata { + grid-column: 2; + grid-row: 2; + display: flex; + align-items: center; + font-size: 11px; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-metadata .col-spacer { + display: none; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-modified, + .dashboard-section-files .files-tab .files.files-list-view .row .item-size { + font-size: 11px; + padding: 0; + line-height: 24px; + color: var(--dashboard-text-muted); + } + + @media (hover: hover) { + .dashboard-section-files .files-tab .files.files-list-view .row:hover .item-modified, + .dashboard-section-files .files-tab .files.files-list-view .row:hover .item-size, + .dashboard-section-files .files-tab .files.files-list-view .row.selected .item-modified, + .dashboard-section-files .files-tab .files.files-list-view .row.selected .item-size { + /* color: var(--primary-color-sidebar-item); */ + } + } + + /* Bullet separator between size and modified */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-size:not(:empty)::after { + content: '•'; + margin: 0 6px; + } + + /* Hide outer spacers */ + .dashboard-section-files .files-tab .files.files-list-view .row > .col-spacer { + display: none; + } + + /* Hide more button (use long-press for context menu on touch) */ + .dashboard-section-files .files-tab .files.files-list-view .row .item-more { + position: absolute; + right: 10px; + } + + /* Adjust footer position - full width since sidebar is hidden */ + .dashboard-section-files .files-footer { + left: 0; + padding: 0; + } + + /* Mobile: Floating selection actions - icon-only, full width */ + .dashboard-section-files .files-selection-actions { + left: 0; + right: 0; + bottom: 38px; + transform: translateX(0) translateY(100px); + border-radius: 0; + justify-content: center; + padding: 10px 8px; + } + + .dashboard-section-files .files-selection-actions.visible { + transform: translateX(0) translateY(0); + } + + .dashboard-section-files .selection-action-btn span { + display: none; + } + + .dashboard-section-files .selection-action-btn { + padding: 9px 8px; + border-radius: 50%; + } + + /* Mobile: Show select mode button */ + .dashboard-section-files .header .path-action-btn.select-mode-btn { + display: flex; + } + + .dashboard-section-files .header .path-action-btn.select-mode-btn.active { + background: var(--primary-color, #3b82f6); + color: white; + border-radius: 6px; + } + + /* Mobile: Show checkboxes in select mode */ + .dashboard-section-files .files-tab.select-mode-active .files .row .item-checkbox { + display: flex; + grid-row: 1 / 3; + } + + /* Mobile: Adjust grid for checkbox in list view */ + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row { + grid-template-columns: 32px 48px 1fr !important; + } + + /* Adjust grid-column for content when checkbox is visible */ + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row .item-icon { + grid-column: 2; + } + + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row .item-name-wrapper { + grid-column: 3; + } + + .dashboard-section-files .files-tab.select-mode-active .files.files-list-view .row .item-metadata { + grid-column: 3; + } + + /* Mobile: Show Done button in floating action bar during select mode */ + .dashboard-section-files .files-tab.select-mode-active .files-selection-actions .done-btn { + display: flex; + background: var(--primary-color, #3b82f6); + color: white; + padding: 5px; + margin-left: 8px; + } + + /* Mobile: Grid view checkbox positioning */ + .dashboard-section-files .files-tab.select-mode-active .files.files-grid-view .row { + position: relative; + } + + .dashboard-section-files .files-tab.select-mode-active .files.files-grid-view .row .item-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + } +} + /* Full HD */ @media (min-width: 1920px) { .dashboard-section-home .bento-container { diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index b64ffd942..426a980b6 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -1919,6 +1919,19 @@ span.header-sort-icon img { border: none; } +/* TabFiles rubber band selection area */ +.tabfiles-selection-area { + background-color: rgba(59, 130, 246, 0.15); + border: 1px solid var(--select-color); + position: absolute; + pointer-events: none; + z-index: 1000; +} + +.dashboard-section-files .files { + position: relative; +} + .container { user-select: none; } diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index d8e915b63..ce6c1fc3e 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -1817,8 +1817,12 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { // update all shortcut_to_path $(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', fsentry.path); - // Remove all items with matching uids - $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).fadeOut(150, function () { + // Remove all items with matching uids from their OLD location(s). + // Exclude any row already at the item's new path: a concurrent + // item.moved socket handler may have just created a row at the + // destination (e.g. the dashboard file view showing the target + // directory), and removing by uid alone would delete it too. + $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).not(`[data-path="${html_encode(fsentry.path)}" i]`).fadeOut(150, function () { // find all parent windows that contain this item let parent_windows = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).closest('.window'); // remove this item @@ -1890,6 +1894,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { suggested_apps: fsentry.suggested_apps, }; UIItem(options); + // In dashboard mode, also create item via dashboard's renderer + if ( window.is_dashboard_mode && window.UIDashboardFileItem ) { + window.UIDashboardFileItem(fsentry); + } moved_items.push({ 'options': options, 'original_path': $(el_item).attr('data-path') }); // this operation may have created some missing directories, @@ -1915,6 +1923,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { suggested_apps: dir.suggested_apps, }); } + // In dashboard mode, also create parent dirs via dashboard's renderer + if ( window.is_dashboard_mode && window.UIDashboardFileItem ) { + window.UIDashboardFileItem(dir); + } window.sort_items(item_container); }); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 75552a2df..af63f8354 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -532,15 +532,21 @@ if (jQuery) { /** * Parses the dashboard URL hash into a route object. * Apps is the default tab (root URL / no hash); Home is reached via #home. - * Hash format: #home or #usage or #account etc. - * @returns {{ tab: string }} Route object with tab name + * Hash format: #files/username/Documents or #home or #usage or #account etc. + * @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path */ function parseDashboardRoute() { - const hash = decodeURIComponent(window.location.hash.slice(1)); - if (!hash) return { tab: 'apps' }; + const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding + if (!hash) return { tab: 'apps', path: null }; - const tab = hash.split('/').filter(Boolean)[0]; - return { tab: tab || 'apps' }; + const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents'] + const tab = parts[0]; // 'files', 'usage', 'account', 'security' + + if (tab === 'files' && parts.length > 1) { + const filePath = `/${parts.slice(1).join('/')}`; // /username/Documents + return { tab: 'files', path: filePath }; + } + return { tab: tab || 'apps', path: null }; } // Make parseDashboardRoute available globally for hashchange handler