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