From 973d046d33ec35643ec5fa45dbf7a3b19cf9f1ff Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 14 May 2026 12:13:48 -0700 Subject: [PATCH] Remove legacy TabFiles component Delete the large TabFiles.js file (file browser tab) and update dashboard, styles, helpers, and init logic accordingly. Related changes in UIDashboard.js, dashboard.css, style.css, helpers.js and initgui.js remove or adapt references to the removed component and adjust UI/initialization and styling to account for the file-tab extraction. This reduces duplicate/legacy code and centralizes file browser functionality; review UIDashboard and helper updates to ensure file management features (navigation, upload, context menus, previews) are migrated or re-implemented as needed. --- src/gui/src/UI/Dashboard/TabFiles.js | 3913 ----------------------- src/gui/src/UI/Dashboard/UIDashboard.js | 107 - src/gui/src/css/dashboard.css | 1336 -------- src/gui/src/css/style.css | 13 - src/gui/src/helpers.js | 8 - src/gui/src/initgui.js | 18 +- 6 files changed, 6 insertions(+), 5389 deletions(-) delete mode 100644 src/gui/src/UI/Dashboard/TabFiles.js diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js deleted file mode 100644 index 3474280e0..000000000 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ /dev/null @@ -1,3913 +0,0 @@ -/** - * 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 ) { - const displayName = file.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 - const folderName = folderElement.getAttribute('data-folder'); - const directories = Object.keys(window.user.directories); - const targetPath = directories.find(f => f.endsWith(folderName)); - - 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).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 folderName = folderElement.getAttribute('data-folder'); - const directories = Object.keys(window.user.directories); - const folderUid = window.user.directories[directories.find(f => f.endsWith(folderName))]; - - if ( folderUid !== _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 === this || 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'), type: 'primary' }, - { label: i18n('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 - const _this = this; - $(document).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'), type: 'primary' }, - { label: i18n('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; - const newName = (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 = await window.puter.fs.readdir(readdirArg); - 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).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); - await Promise.all(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++; - const metadata = JSON.parse(file.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 !== undefined ? "1" : "0"); - row.setAttribute("data-worker_url", is_worker !== undefined ? 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} -
-
- - - - -
-
-
${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).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'), type: 'primary' }, - { label: i18n('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); - } - }, - }); - } - - // 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 e4aea9285..74de9882a 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -44,7 +44,6 @@ 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'; @@ -54,7 +53,6 @@ import TabSecurity from './TabSecurity.js'; const builtinTabs = [ TabHome, TabApps, - TabFiles, '-', TabUsage, TabAccount, @@ -156,11 +154,6 @@ 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 ) { @@ -232,97 +225,6 @@ 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 old item from view - $(`.item[data-uid='${resp.uid}']`).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) => { - 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; @@ -356,7 +258,6 @@ 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}"]`); @@ -378,14 +279,6 @@ 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 d69dc0020..865a4e208 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -900,1078 +900,6 @@ input.myapps-search::placeholder { 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; @@ -2108,270 +1036,6 @@ input.myapps-search::placeholder { } } -/* 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 1f79ca56c..67c1a1dbb 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -1910,19 +1910,6 @@ 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 0f3123ba5..974abef92 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -1735,10 +1735,6 @@ 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, @@ -1764,10 +1760,6 @@ 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 f03e1b26d..a134b94af 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -160,21 +160,15 @@ if ( window.location.pathname === '/dashboard' || window.location.pathname === ' /** * Parses the dashboard URL hash into a route object. - * Hash format: #files/username/Documents or #usage or #account etc. - * @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path + * Hash format: #usage or #account etc. + * @returns {{ tab: string }} Route object with tab name */ function parseDashboardRoute () { - const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding - if ( ! hash ) return { tab: 'home', path: null }; + const hash = decodeURIComponent(window.location.hash.slice(1)); + if ( ! hash ) return { tab: 'home' }; - 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 || 'home', path: null }; + const tab = hash.split('/').filter(Boolean)[0]; + return { tab: tab || 'home' }; } // Make parseDashboardRoute available globally for hashchange handler