mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f805d7bf37 | ||
|
|
48a5c7d7bb | ||
|
|
910fe2d334 | ||
|
|
c5c559514e | ||
|
|
cf514a4515 | ||
|
|
7b8106757e | ||
|
|
382e6274fe | ||
|
|
3d19201a2d | ||
|
|
492e339a74 | ||
|
|
87b6b34545 |
No files matched your search
@@ -39,6 +39,7 @@ import { openPublishWebsiteDialog, openPublishWorkerDialog } from './UIPublishDi
|
||||
import { dedupedName } from '../../helpers/dedupedName.js';
|
||||
import { compareEntries, compareNames } from './sortEntries.js';
|
||||
import { middleTruncate } from './middleTruncate.js';
|
||||
import { resolveTypedPath } from './typedPath.js';
|
||||
import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js';
|
||||
|
||||
import { icons } from '../../helpers/actionIcons.js';
|
||||
@@ -163,6 +164,7 @@ const TabFiles = {
|
||||
<img draggable="false" class="path-btn path-btn-forward path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_forward')}" src="${html_encode(window.icons['arrow-right-thin.svg'])}" title="${i18n('window_click_to_go_forward')}">
|
||||
<img draggable="false" class="path-btn path-btn-up path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_up')}" src="${html_encode(window.icons['arrow-up-thin.svg'])}" title="${i18n('window_click_to_go_up')}">
|
||||
</div>
|
||||
<input type="text" class="path-editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" enterkeyhint="go" aria-label="${i18n('path')}">
|
||||
<div class="path-breadcrumbs"></div>
|
||||
<div class="path-actions">
|
||||
<button class="path-action-btn select-mode-btn" title="${i18n('select')}" aria-pressed="false">${icons.select}</button>
|
||||
@@ -836,6 +838,142 @@ const TabFiles = {
|
||||
if ( crumbs ) crumbs.scrollLeft = crumbs.scrollWidth;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lets the path bar be typed into: a click on it anywhere but on a crumb
|
||||
* turns it into a text field, as in an Explorer window.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
bindPathEditor () {
|
||||
this.$el_window.on('click', '.files-tab .path-breadcrumbs', (e) => {
|
||||
if ( $(e.target).closest('.dirname').length ) return;
|
||||
this.openPathEditor();
|
||||
});
|
||||
this.$el_window.on('keydown', '.files-tab .path-editor', (e) => {
|
||||
// Enter and Escape mid-composition belong to the IME; jQuery's
|
||||
// event doesn't carry isComposing, the browser's does.
|
||||
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
|
||||
if ( e.key === 'Enter' ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.submitPathEditor();
|
||||
} else if ( e.key === 'Escape' ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.closePathEditor({ refocus: true });
|
||||
}
|
||||
});
|
||||
this.$el_window.on('input', '.files-tab .path-editor', (e) => {
|
||||
e.currentTarget.removeAttribute('aria-invalid');
|
||||
});
|
||||
this.$el_window.on('focusout', '.files-tab .path-editor', () => {
|
||||
if ( ! this.pathLookup ) this.closePathEditor();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Swaps the breadcrumbs for a text field to type a path into.
|
||||
*
|
||||
* @param {string} [initial] - Text to start from, with the caret after
|
||||
* it; by default the current path, selected
|
||||
* @returns {void}
|
||||
*/
|
||||
openPathEditor (initial) {
|
||||
const $path = this.$el_window?.find('.files-tab .header .path');
|
||||
const input = $path?.find('.path-editor')[0];
|
||||
if ( !input || !this.currentPath ) return;
|
||||
input.value = initial ?? this.currentPath;
|
||||
input.removeAttribute('aria-invalid');
|
||||
$path.addClass('path-editing');
|
||||
input.focus();
|
||||
if ( initial === undefined ) input.select();
|
||||
else input.setSelectionRange(input.value.length, input.value.length);
|
||||
},
|
||||
|
||||
/**
|
||||
* Puts the breadcrumbs back in place of the path field.
|
||||
*
|
||||
* @param {{ refocus?: boolean }} [options] - refocus hands the keyboard
|
||||
* back to the listing
|
||||
* @returns {void}
|
||||
*/
|
||||
closePathEditor ({ refocus = false } = {}) {
|
||||
const $path = this.$el_window?.find('.files-tab .header .path');
|
||||
if ( ! $path?.hasClass('path-editing') ) return;
|
||||
$path.removeClass('path-editing');
|
||||
// A lookup still running for the closed field is called off.
|
||||
this.pathLookup = null;
|
||||
// Hidden, the crumbs lost their scroll position.
|
||||
this.revealCurrentCrumb();
|
||||
if ( refocus ) this.focusListing();
|
||||
},
|
||||
|
||||
/**
|
||||
* Goes where the path field says: into a folder, or to a file's folder
|
||||
* with the file selected. A path to nothing leaves the field open.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async submitPathEditor () {
|
||||
// A second Enter during the lookup would look it up again, and a
|
||||
// path to nothing would get a second alert.
|
||||
if ( this.pathLookup ) return;
|
||||
const input = this.$el_window.find('.files-tab .path-editor')[0];
|
||||
const typed = input.value.trim();
|
||||
const target = resolveTypedPath(typed, {
|
||||
currentPath: this.currentPath,
|
||||
homePath: window.home_path,
|
||||
sharedPath: window.shared_path,
|
||||
});
|
||||
if ( target === undefined || target === this.currentPath ) {
|
||||
this.closePathEditor({ refocus: true });
|
||||
return;
|
||||
}
|
||||
|
||||
let folder = target;
|
||||
let file;
|
||||
// Neither Shared nor the root can be stat'ed, and both are folders.
|
||||
if ( target !== window.shared_path && target !== '/' ) {
|
||||
// The stat and the alert take the focus from the field, which
|
||||
// would close it.
|
||||
const lookup = this.pathLookup = {};
|
||||
try {
|
||||
let entry;
|
||||
try {
|
||||
entry = await puter.fs.stat({ path: target, consistency: 'eventual' });
|
||||
} catch {
|
||||
entry = undefined;
|
||||
}
|
||||
if ( this.pathLookup !== lookup ) return;
|
||||
if ( ! entry ) {
|
||||
input.setAttribute('aria-invalid', 'true');
|
||||
// Settled on close: Escape closes the alert without
|
||||
// settling what UIAlert returns.
|
||||
await new Promise(resolve => UIAlert({
|
||||
message: i18n('path_not_found', [typed], false),
|
||||
window_options: { on_close: resolve },
|
||||
}));
|
||||
input.focus();
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
if ( ! entry.is_dir ) {
|
||||
file = entry.path ?? target;
|
||||
folder = path.dirname(file);
|
||||
}
|
||||
} finally {
|
||||
if ( this.pathLookup === lookup ) this.pathLookup = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.closePathEditor({ refocus: true });
|
||||
if ( folder !== this.currentPath ) {
|
||||
this.pushNavHistory(folder);
|
||||
await this.renderDirectory(folder);
|
||||
}
|
||||
if ( file ) this.selectRowsWhere(row => row.getAttribute('data-path') === file);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the Dashboard Files tab is currently active and visible.
|
||||
*
|
||||
@@ -1253,6 +1391,14 @@ const TabFiles = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No name holds a slash, so typing one starts a path to go to.
|
||||
if ( e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && _this.typeSearchTerm === '' ) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
_this.openPathEditor('/');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type-to-select: letter/number keys search items by name
|
||||
if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) {
|
||||
e.preventDefault();
|
||||
@@ -1414,6 +1560,8 @@ const TabFiles = {
|
||||
this.click();
|
||||
});
|
||||
|
||||
this.bindPathEditor();
|
||||
|
||||
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`);
|
||||
@@ -5886,6 +6034,10 @@ const TabFiles = {
|
||||
|
||||
this.$el_window.find('.files-tab .files .row.selected').removeClass('selected');
|
||||
rows.addClass('selected');
|
||||
// The arrow keys and Shift+click go on from here, not from a row
|
||||
// selected before.
|
||||
window.active_element = rows[0];
|
||||
window.latest_selected_item = rows[0];
|
||||
this.updateFooterStats();
|
||||
rows[0].scrollIntoView({ block: 'nearest' });
|
||||
},
|
||||
|
||||
@@ -1426,3 +1426,251 @@ describe('TabFiles listSharedView', () => {
|
||||
expect(rows[1].holder_team).toEqual(team);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles path field', () => {
|
||||
let UIAlert;
|
||||
|
||||
/** The tab's real header and an empty listing, its listeners bound. */
|
||||
const mountHeader = (currentPath = '/alice/Documents') => {
|
||||
// Drag and drop is jQuery UI's, which isn't loaded here.
|
||||
$.fn.droppable ??= function () {
|
||||
return this;
|
||||
};
|
||||
$.fn.draggable ??= function () {
|
||||
return this;
|
||||
};
|
||||
document.body.innerHTML = `<div class="window">${TabFiles.html()}</div>`;
|
||||
TabFiles.$el_window = $('.window');
|
||||
TabFiles.currentPath = currentPath;
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
$('.path-breadcrumbs').html(TabFiles.renderPath(currentPath));
|
||||
TabFiles.bindPathEditor();
|
||||
};
|
||||
const editor = () => $('.path-editor')[0];
|
||||
const editing = () => $('.header .path').hasClass('path-editing');
|
||||
const keydown = (el, key) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
|
||||
beforeEach(async () => {
|
||||
({ default: UIAlert } = await import('../UIAlert.js'));
|
||||
UIAlert.mockClear();
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.home_path = '/alice';
|
||||
window.trash_path = TRASH;
|
||||
window.shared_path = 'puter://shared';
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
window.dashboard_nav_history = ['/alice/Documents'];
|
||||
window.dashboard_nav_history_current_position = 0;
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
window.puter = { fs: {
|
||||
readdir: vi.fn(async () => []),
|
||||
stat: vi.fn(async ({ path }) => {
|
||||
if ( path === '/alice/Pictures' ) return { path, is_dir: true };
|
||||
if ( path === '/alice/Pictures/cat.png' ) return { path, is_dir: false };
|
||||
throw { code: 'subject_does_not_exist' };
|
||||
}),
|
||||
} };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TabFiles.$el_window.off();
|
||||
$(document).off('keydown.tabfiles');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens with the current path selected on a click past the crumbs', () => {
|
||||
mountHeader();
|
||||
|
||||
$('.path-breadcrumbs')[0].click();
|
||||
|
||||
expect(editing()).toBe(true);
|
||||
expect(document.activeElement).toBe(editor());
|
||||
expect(editor().value).toBe('/alice/Documents');
|
||||
expect(editor().selectionStart).toBe(0);
|
||||
expect(editor().selectionEnd).toBe('/alice/Documents'.length);
|
||||
});
|
||||
|
||||
it('keeps a phone keyboard from rewriting the path, and offers Go', () => {
|
||||
mountHeader();
|
||||
|
||||
expect(editor().getAttribute('autocorrect')).toBe('off');
|
||||
expect(editor().getAttribute('autocapitalize')).toBe('off');
|
||||
expect(editor().getAttribute('enterkeyhint')).toBe('go');
|
||||
});
|
||||
|
||||
it('stays closed on a click on a crumb', () => {
|
||||
mountHeader();
|
||||
vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
|
||||
|
||||
$('.path-breadcrumbs .dirname').first()[0].click();
|
||||
|
||||
expect(editing()).toBe(false);
|
||||
});
|
||||
|
||||
it('opens on a typed slash, holding just the slash', () => {
|
||||
mountHeader();
|
||||
vi.spyOn(TabFiles, 'isDashboardFilesActive').mockReturnValue(true);
|
||||
TabFiles.typeSearchTerm = '';
|
||||
TabFiles.setupKeyboardShortcuts();
|
||||
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '/', which: 191, bubbles: true }));
|
||||
|
||||
expect(editing()).toBe(true);
|
||||
expect(editor().value).toBe('/');
|
||||
});
|
||||
|
||||
it('goes into the folder typed, and Back returns', async () => {
|
||||
mountHeader();
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = '../Pictures';
|
||||
|
||||
await TabFiles.submitPathEditor();
|
||||
|
||||
expect(TabFiles.currentPath).toBe('/alice/Pictures');
|
||||
expect(window.dashboard_nav_history).toEqual(['/alice/Documents', '/alice/Pictures']);
|
||||
expect(editing()).toBe(false);
|
||||
});
|
||||
|
||||
it('goes to the root, which can\'t be looked up, from / or ..', async () => {
|
||||
for ( const typed of ['/', '..'] ) {
|
||||
mountHeader('/alice');
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = typed;
|
||||
|
||||
await TabFiles.submitPathEditor();
|
||||
|
||||
expect(TabFiles.currentPath).toBe('/');
|
||||
}
|
||||
expect(window.puter.fs.stat).not.toHaveBeenCalled();
|
||||
expect(UIAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a typed file\'s folder with the file selected', async () => {
|
||||
mountHeader();
|
||||
window.puter.fs.readdir = vi.fn(async () => [{ name: 'cat.png', path: '/alice/Pictures/cat.png', is_dir: false, uid: 'u-cat', size: 1, modified: 0 }]);
|
||||
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
|
||||
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}"></div>`);
|
||||
});
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = '~/Pictures/cat.png';
|
||||
|
||||
await TabFiles.submitPathEditor();
|
||||
|
||||
expect(TabFiles.currentPath).toBe('/alice/Pictures');
|
||||
expect($('.files .row.selected').attr('data-path')).toBe('/alice/Pictures/cat.png');
|
||||
});
|
||||
|
||||
it('moves the arrow keys\' starting row to a typed file in the folder on screen', async () => {
|
||||
mountHeader('/alice/Pictures');
|
||||
$('.files').html(['a.png', 'cat.png', 'z.png'].map(name => `<div class="row item" data-name="${name}" data-path="/alice/Pictures/${name}"></div>`).join(''));
|
||||
const [first, cat] = $('.files .row').toArray();
|
||||
first.classList.add('selected');
|
||||
window.latest_selected_item = first;
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = 'cat.png';
|
||||
|
||||
await TabFiles.submitPathEditor();
|
||||
|
||||
expect($('.files .row.selected').toArray()).toEqual([cat]);
|
||||
expect(window.latest_selected_item).toBe(cat);
|
||||
expect(window.active_element).toBe(cat);
|
||||
});
|
||||
|
||||
it('stays open, marked, on a path to nothing', async () => {
|
||||
mountHeader();
|
||||
const render = vi.spyOn(TabFiles, 'renderDirectory');
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = '/alice/Gone';
|
||||
// The alert takes the focus, which must not close the field.
|
||||
UIAlert.mockImplementation(async ({ window_options }) => {
|
||||
editor().blur();
|
||||
window_options.on_close();
|
||||
return true;
|
||||
});
|
||||
|
||||
await TabFiles.submitPathEditor();
|
||||
|
||||
expect(UIAlert.mock.calls[0][0].message).toContain('/alice/Gone');
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
expect(editing()).toBe(true);
|
||||
expect(editor().getAttribute('aria-invalid')).toBe('true');
|
||||
expect(document.activeElement).toBe(editor());
|
||||
});
|
||||
|
||||
it('takes the field back when the alert is closed without its button', async () => {
|
||||
mountHeader();
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = '/alice/Gone';
|
||||
// Escape removes the alert, and what UIAlert returns never settles.
|
||||
UIAlert.mockImplementation(({ window_options }) => {
|
||||
editor().blur();
|
||||
window_options.on_close();
|
||||
return new Promise(() => {});
|
||||
});
|
||||
|
||||
await TabFiles.submitPathEditor();
|
||||
|
||||
expect(document.activeElement).toBe(editor());
|
||||
editor().blur();
|
||||
expect(editing()).toBe(false);
|
||||
});
|
||||
|
||||
it('looks a path up once, however often Enter is pressed meanwhile', async () => {
|
||||
mountHeader();
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = '/alice/Gone';
|
||||
UIAlert.mockImplementation(async ({ window_options }) => window_options.on_close());
|
||||
|
||||
await Promise.all([TabFiles.submitPathEditor(), TabFiles.submitPathEditor()]);
|
||||
|
||||
expect(window.puter.fs.stat).toHaveBeenCalledTimes(1);
|
||||
expect(UIAlert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls off a lookup on Escape, and takes a new path at once', async () => {
|
||||
mountHeader();
|
||||
const render = vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
|
||||
let settle;
|
||||
window.puter.fs.stat.mockImplementationOnce(() => new Promise(resolve => {
|
||||
settle = resolve;
|
||||
}));
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = '/alice/Gone';
|
||||
|
||||
const calledOff = TabFiles.submitPathEditor();
|
||||
keydown(editor(), 'Escape');
|
||||
TabFiles.openPathEditor();
|
||||
editor().value = '/alice/Pictures';
|
||||
await TabFiles.submitPathEditor();
|
||||
settle(undefined);
|
||||
await calledOff;
|
||||
|
||||
expect(render.mock.calls).toEqual([['/alice/Pictures']]);
|
||||
expect(UIAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves Enter and Escape to an IME composing a name', () => {
|
||||
mountHeader();
|
||||
const submit = vi.spyOn(TabFiles, 'submitPathEditor').mockResolvedValue();
|
||||
TabFiles.openPathEditor();
|
||||
|
||||
for ( const key of ['Enter', 'Escape'] ) {
|
||||
editor().dispatchEvent(new KeyboardEvent('keydown', { key, isComposing: true, bubbles: true }));
|
||||
editor().dispatchEvent(new KeyboardEvent('keydown', { key: 'Process', keyCode: 229, bubbles: true }));
|
||||
}
|
||||
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(editing()).toBe(true);
|
||||
});
|
||||
|
||||
it('gives the crumbs back on Escape or when it loses the focus', () => {
|
||||
mountHeader();
|
||||
TabFiles.openPathEditor();
|
||||
keydown(editor(), 'Escape');
|
||||
expect(editing()).toBe(false);
|
||||
|
||||
TabFiles.openPathEditor();
|
||||
editor().blur();
|
||||
expect(editing()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from '../../lib/path.js';
|
||||
|
||||
/**
|
||||
* Where a path typed into the Files tab's path bar leads: `~` is the home
|
||||
* folder, and a relative path starts from the folder on screen.
|
||||
*
|
||||
* @param {string} typed
|
||||
* @param {{ currentPath: string, homePath: string, sharedPath: string }} places
|
||||
* @returns {string | undefined} An absolute path, the Shared view's path, or
|
||||
* undefined when nothing was typed
|
||||
*/
|
||||
export function resolveTypedPath (typed, { currentPath, homePath, sharedPath }) {
|
||||
const text = typed.trim();
|
||||
if ( text === '' ) return undefined;
|
||||
if ( text.replace(/\/+$/, '') === sharedPath ) return sharedPath;
|
||||
if ( text === '~' || text.startsWith('~/') ) return path.resolve(homePath, `.${text.slice(1)}`);
|
||||
// The Shared view is a query, not a folder a relative path can start from.
|
||||
const base = currentPath?.startsWith('/') ? currentPath : homePath;
|
||||
return path.resolve(base, text);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveTypedPath } from './typedPath.js';
|
||||
|
||||
const places = { currentPath: '/alice/Documents', homePath: '/alice', sharedPath: 'puter://shared' };
|
||||
|
||||
describe('resolveTypedPath', () => {
|
||||
it('takes an absolute path as typed, without the trailing slash', () => {
|
||||
expect(resolveTypedPath('/bob/Public/', places)).toBe('/bob/Public');
|
||||
expect(resolveTypedPath('/', places)).toBe('/');
|
||||
});
|
||||
|
||||
it('ignores the space around a pasted path', () => {
|
||||
expect(resolveTypedPath(' /alice/Pictures \n', places)).toBe('/alice/Pictures');
|
||||
});
|
||||
|
||||
it('reads ~ as the home folder', () => {
|
||||
expect(resolveTypedPath('~', places)).toBe('/alice');
|
||||
expect(resolveTypedPath('~/Desktop', places)).toBe('/alice/Desktop');
|
||||
});
|
||||
|
||||
it('starts a relative path from the folder on screen', () => {
|
||||
expect(resolveTypedPath('Work/2026', places)).toBe('/alice/Documents/Work/2026');
|
||||
expect(resolveTypedPath('../Videos', places)).toBe('/alice/Videos');
|
||||
expect(resolveTypedPath('/alice/./Documents/../Public', places)).toBe('/alice/Public');
|
||||
});
|
||||
|
||||
it('starts a relative path from home in the Shared view', () => {
|
||||
expect(resolveTypedPath('Desktop', { ...places, currentPath: 'puter://shared' })).toBe('/alice/Desktop');
|
||||
});
|
||||
|
||||
it('keeps the Shared view\'s path', () => {
|
||||
expect(resolveTypedPath('puter://shared/', places)).toBe('puter://shared');
|
||||
});
|
||||
|
||||
it('leads nowhere when nothing was typed', () => {
|
||||
expect(resolveTypedPath(' ', places)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a ~ inside a name is just a character', () => {
|
||||
expect(resolveTypedPath('~notes', places)).toBe('/alice/Documents/~notes');
|
||||
});
|
||||
});
|
||||
@@ -4421,6 +4421,47 @@ body.myapps-reordering .myapps-tile {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
/* The row's empty space is the bar's, so a click there can open the
|
||||
path for typing (see openPathEditor). */
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Keeps a click target past the last crumb when a deep path fills the row. */
|
||||
.dashboard-section-files .header .path-breadcrumbs:not(:empty)::after {
|
||||
content: '';
|
||||
flex: 0 0 24px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path-editor {
|
||||
display: none;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
/* Clear of the action buttons by their own 10px gap */
|
||||
margin: 0 10px;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--select-color);
|
||||
border-radius: 6px;
|
||||
background: var(--dashboard-input-background);
|
||||
color: var(--dashboard-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path-editor[aria-invalid="true"] {
|
||||
border-color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path.path-editing .path-editor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path.path-editing .path-breadcrumbs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path-breadcrumbs::-webkit-scrollbar {
|
||||
@@ -6096,6 +6137,26 @@ body.myapps-reordering .myapps-tile {
|
||||
border-bottom: 1px solid var(--dashboard-border);
|
||||
}
|
||||
|
||||
/* Sized as a crumb is, so the header doesn't jump as the field opens */
|
||||
.dashboard-section-files .header .path-editor {
|
||||
order: 1;
|
||||
flex: 0 1 100%;
|
||||
margin: 0 0 8px 0;
|
||||
height: auto;
|
||||
padding: 3px 8px;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* The crumbs' divider, kept while the field stands in for them */
|
||||
.dashboard-section-files .header .path.path-editing::after {
|
||||
content: '';
|
||||
order: 1;
|
||||
flex: 0 1 100%;
|
||||
height: 1px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--dashboard-border);
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path-nav-buttons {
|
||||
order: 2;
|
||||
border-right: none;
|
||||
@@ -6280,6 +6341,17 @@ body.myapps-reordering .myapps-tile {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path-editor {
|
||||
margin-left: 50px;
|
||||
height: 40px;
|
||||
/* Under 16px, iOS zooms the page in on focus. */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path.path-editing::after {
|
||||
margin-left: 50px;
|
||||
}
|
||||
|
||||
/* Finger-sized crumbs, in that row */
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-section-files .header .path .dirname {
|
||||
|
||||
@@ -283,6 +283,7 @@ const en = {
|
||||
items_selected: '{{items}} selected',
|
||||
folder_empty: 'No files in this directory.',
|
||||
folder_open_failed: 'This folder couldn\'t be opened.',
|
||||
path_not_found: 'Couldn\'t find "%%". Check the path and try again.',
|
||||
reserved_name_not_allowed: 'The name "{{name}}" is not allowed, because it is a reserved name. Please choose another name.',
|
||||
upload_some_failed: 'Some uploads failed:',
|
||||
upload_to_trash_not_allowed: 'Uploading to trash is not allowed!',
|
||||
|
||||
Reference in new issue
Block a user