Compare commits

...
Author SHA1 Message Date
Nariman JelvehandClaude Opus 5.5 3e11e7dc79 refactor: keep folder sort and layout in puter.kv, drop /set_sort_by and /set_layout (#4642)
Explorer saved each folder's sort and layout through POST /set_sort_by and
/set_layout, which wrote them onto the folder's fsentries row. stat only
returns `layout`, so a saved sort was never read back.

The GUI now keeps them in the user's key-value store, one key per folder
uid (`folder_view:<uid>` holding `{ sort_by, sort_order, layout }`), via
helpers/folderViewPrefs.js. A change to one preference keeps the others.
A folder with nothing stored falls back to the layout its fsentry still
carries, then to the defaults, so existing layouts carry over.

refresh_item_container reads the folder's view after stat and re-sorts
items that went in under the previous sort; a view for a folder the
window has left is ignored. update_window_path no longer applies sort and
layout itself, since the refresh it starts does.

With no callers left, DesktopController and its two routes are removed.
The fsentries columns and stat's `layout` field stay.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 21:34:37 -07:00
8 changed files with 327 additions and 620 deletions

No files matched your search

@@ -1,165 +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 <https://www.gnu.org/licenses/>.
*/
import { HttpError } from '../../core/http/HttpError.js';
import { PuterController } from '../types.js';
const ALLOWED_LAYOUTS = ['icons', 'details', 'list'];
const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
/**
* Folder view preference routes: layout and sort_by/sort_order, stored on the
* folder's fsentries row.
*/
/**
* Desktop preference writes — layout, sort order. Each persists on every call,
* and the GUI fires them on direct user action, so a per-minute ceiling well
* above human speed is enough to catch a stuck client.
*/
const PREFERENCE_WRITE_LIMIT = {
scope: 'desktop-preference',
limit: 120,
window: 60_000,
key: 'user',
};
export class DesktopController extends PuterController {
registerRoutes(router) {
// -- Folder layout -------------------------------------------
router.post(
'/set_layout',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, layout } = req.body ?? {};
if (!layout || !ALLOWED_LAYOUTS.includes(layout)) {
throw new HttpError(
400,
`\`layout\` must be one of: ${ALLOWED_LAYOUTS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{ layout },
);
res.json({});
},
);
// -- Folder sort ---------------------------------------------
router.post(
'/set_sort_by',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, sort_by, sort_order } =
req.body ?? {};
if (!sort_by || !ALLOWED_SORT_BY.includes(sort_by)) {
throw new HttpError(
400,
`\`sort_by\` must be one of: ${ALLOWED_SORT_BY.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
const resolvedOrder = sort_order ?? 'asc';
if (!ALLOWED_SORT_ORDER.includes(resolvedOrder)) {
throw new HttpError(
400,
`\`sort_order\` must be one of: ${ALLOWED_SORT_ORDER.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{
sort_by,
sort_order: resolvedOrder,
},
);
res.json({});
},
);
}
// -- Helpers ------------------------------------------------------
/**
* Update columns on an actor-owned fsentry. Accepts either `item_uid` (fast
* path) or `item_path` (path lookups in FSEntryStore now have a
* recursive-CTE fallback for legacy rows with a NULL `path` column, so this
* works for old accounts too).
*
* Ownership: `entry.user_id === actor.user.id`. Kept as added validation
* against bad paths — the previous "drop user_id entirely" theory turned
* out to be wrong (the actual legacy issue was NULL paths, not user_id
* drift), so this filter doesn't lock out old accounts in practice.
*/
async #updateFSEntry(actor, { item_uid, item_path }, patch) {
if (!item_uid && !item_path) {
throw new HttpError(400, 'Missing `item_uid` or `item_path`', {
legacyCode: 'bad_request',
});
}
const entry = item_uid
? await this.stores.fsEntry.getEntryByUuid(item_uid)
: await this.stores.fsEntry.getEntryByPath(item_path);
if (!entry) {
throw new HttpError(404, 'Item not found', {
legacyCode: 'not_found',
});
}
const actorUserId = actor?.user?.id;
if (typeof actorUserId !== 'number' || entry.userId !== actorUserId) {
throw new HttpError(403, 'Not allowed to update this item', {
legacyCode: 'forbidden',
});
}
const keys = Object.keys(patch);
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
const values = keys.map((k) => patch[k]);
await this.clients.db.write(
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
[...values, entry.id],
);
await this.stores.fsEntry.invalidateEntryCacheByUuid(entry.uuid);
}
onServerStart() {}
onServerPrepareShutdown() {}
onServerShutdown() {}
}
@@ -1,381 +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 <https://www.gnu.org/licenses/>.
*/
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
// ── Test harness ────────────────────────────────────────────────────
//
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
// redis) and re-registers DesktopController's inline lambda routes
// onto a fresh PuterRouter so each handler is reachable. Each test
// makes its own user via `makeUser` and exercises the live controller
// against the real wired stores (user, fsEntry) and DB client.
let server: PuterServer;
let router: PuterRouter;
beforeAll(async () => {
server = await setupTestServer();
router = new PuterRouter();
server.controllers.desktop.registerRoutes(router);
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const refreshed = (await server.stores.user.getById(created.id))!;
return {
userId: refreshed.id,
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
username: refreshed.username,
email: refreshed.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
interface CapturedResponse {
statusCode: number;
body: unknown;
}
const makeReq = (init: {
body?: unknown;
actor?: Actor;
}): Request => {
return {
body: init.body ?? {},
query: {},
headers: {},
actor: init.actor,
} as unknown as Request;
};
const makeRes = () => {
const captured: CapturedResponse = { statusCode: 200, body: undefined };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
return res;
}),
status: vi.fn((code: number) => {
captured.statusCode = code;
return res;
}),
setHeader: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
const findHandler = (method: string, path: string): RequestHandler => {
const route = router.routes.find(
(r) => r.method === method && r.path === path,
);
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
return route.handler;
};
const callRoute = async (
method: string,
path: string,
req: Request,
res: Response,
) => {
const handler = findHandler(method, path);
await handler(req, res, () => {
throw new Error('handler called next() unexpectedly');
});
};
describe('DesktopController routes', () => {
it('has no desktop background route', () => {
expect(
router.routes.some((r) => r.path === '/set-desktop-bg'),
).toBe(false);
});
it('has no taskbar items route', () => {
expect(
router.routes.some((r) => r.path === '/update-taskbar-items'),
).toBe(false);
});
});
// ── /set_layout ─────────────────────────────────────────────────────
describe('DesktopController POST /set_layout', () => {
it('persists the new layout on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
expect(documents).not.toBeNull();
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'icons' },
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.layout).toBe('icons');
});
it('resolves by item_path when item_uid is omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Pictures`;
const { res } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_path: path, layout: 'list' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByPath(path);
expect(refreshed?.layout).toBe('list');
});
it('throws 400 for an unknown layout value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'gallery' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when neither item_uid nor item_path is supplied', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({ body: { layout: 'icons' }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 404 when the fsentry cannot be resolved', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: {
item_uid: '00000000-0000-0000-0000-000000000000',
layout: 'icons',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 404 });
});
it("throws 403 when the fsentry belongs to another user", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const ownerUsername = owner.actor.user!.username!;
const ownerEntry = await server.stores.fsEntry.getEntryByPath(
`/${ownerUsername}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: ownerEntry!.uuid, layout: 'icons' },
actor: intruder.actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
// Owner's entry remains untouched.
const refreshed = await server.stores.fsEntry.getEntryByUuid(
ownerEntry!.uuid,
);
expect(refreshed?.layout).toBeFalsy();
});
});
// ── /set_sort_by ────────────────────────────────────────────────────
describe('DesktopController POST /set_sort_by', () => {
it('persists sort_by + sort_order on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: documents!.uuid,
sort_by: 'name',
sort_order: 'desc',
},
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.sortBy).toBe('name');
expect(refreshed?.sortOrder).toBe('desc');
});
it('defaults sort_order to "asc" when omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const pictures = await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures`,
);
const { res } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: pictures!.uuid, sort_by: 'modified' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByUuid(
pictures!.uuid,
);
expect(refreshed?.sortBy).toBe('modified');
expect(refreshed?.sortOrder).toBe('asc');
});
it('throws 400 for an unknown sort_by value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: docs!.uuid, sort_by: 'random' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 for an invalid sort_order', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: docs!.uuid,
sort_by: 'name',
sort_order: 'sideways',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
-2
View File
@@ -21,7 +21,6 @@ import { AppController } from './apps/AppController.js';
import { AppFeedbackController } from './feedback/AppFeedbackController.js';
import { AuthController } from './auth/AuthController.js';
import { BroadcastController } from './broadcast/BroadcastController.js';
import { DesktopController } from './desktop/DesktopController.js';
import { DriverController } from './drivers/DriverController.js';
import { EventsController } from './events/EventsController.js';
import { FSController } from './fs/FSController.js';
@@ -49,7 +48,6 @@ export const puterControllers = {
profile: ProfileController,
apps: AppController,
appFeedback: AppFeedbackController,
desktop: DesktopController,
hosting: HostingController,
system: SystemController,
fs: FSController,
+12 -58
View File
@@ -24,6 +24,7 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { setFolderView } from '../helpers/folderViewPrefs.js';
import { atPath } from '../helpers/pathElements.js';
import { resolve_typed_path } from '../helpers/expandHomePath.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
@@ -3089,9 +3090,6 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
$(el_window_item_container).attr('data-uid', fsentry.id);
// title - use i18n for system directories
if ( target_path === window.home_path )
@@ -3132,12 +3130,7 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).attr('data-path', target_path);
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
window.update_window_layout(el_window, fsentry.layout);
// update explore header if in details view
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
// The folder's sort and layout are applied by refresh_item_container below.
}).catch(() => {
if ( $(el_window).attr('data-path') !== target_path ) {
return;
@@ -3489,27 +3482,7 @@ window.set_sort_by = function (item_uid, sort_by, sort_order) {
sort_order = 'asc';
}
$.ajax({
url: `${window.api_origin }/set_sort_by`,
type: 'POST',
data: JSON.stringify({
sort_by: sort_by,
item_uid: item_uid,
sort_order: sort_order,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
success: function () {
},
});
setFolderView(item_uid, { sort_by, sort_order });
// update the sort_by & sort_order attr of every matching element
$(`[data-uid="${item_uid}"]`).attr({
'data-sort_by': sort_by,
@@ -4987,34 +4960,15 @@ $(document).on('click', '.explore-table-headers-th', function (e) {
});
window.set_layout = function (item_uid, layout) {
$.ajax({
url: `${window.api_origin }/set_layout`,
type: 'POST',
data: JSON.stringify({
item_uid: item_uid,
layout: layout,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
success: function () {
if ( layout === 'details' ) {
let el_window = $(`.window[data-uid="${item_uid}"]`);
if ( el_window.length > 0 ) {
let sort_by = el_window.attr('data-sort_by');
let sort_order = el_window.attr('data-sort_order');
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
}
}
},
});
setFolderView(item_uid, { layout });
if ( layout === 'details' ) {
let el_window = $(`.window[data-uid="${item_uid}"]`);
if ( el_window.length > 0 ) {
let sort_by = el_window.attr('data-sort_by');
let sort_order = el_window.attr('data-sort_order');
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
}
}
};
window.update_details_layout_sort_visuals = function (el_window, sort_by, sort_order) {
+112
View File
@@ -0,0 +1,112 @@
/*
* 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/>.
*/
/**
* How each folder is shown — its sort and its layout — kept in the user's
* key-value store, one key per folder uid, as `{ sort_by, sort_order, layout }`.
*/
export const LAYOUTS = ['icons', 'list', 'details'];
export const SORT_BYS = ['name', 'modified', 'type', 'size'];
export const SORT_ORDERS = ['asc', 'desc'];
export const DEFAULT_FOLDER_VIEW = Object.freeze({
sort_by: 'name',
sort_order: 'asc',
layout: 'icons',
});
const ALLOWED = { sort_by: SORT_BYS, sort_order: SORT_ORDERS, layout: LAYOUTS };
export const folderViewKey = uid => `folder_view:${uid}`;
/**
* The known preferences in `value`, without anything unrecognized.
*
* @param {unknown} value
* @returns {{ sort_by?: string, sort_order?: string, layout?: string }}
*/
export function pickFolderView(value) {
if ( typeof value === 'string' ) {
try {
value = JSON.parse(value);
} catch {
return {};
}
}
const picked = {};
if ( !value || typeof value !== 'object' ) return picked;
for ( const [field, allowed] of Object.entries(ALLOWED) ) {
if ( allowed.includes(value[field]) ) picked[field] = value[field];
}
return picked;
}
const usableUid = uid => typeof uid === 'string' && uid !== '' && uid !== 'null' && uid !== 'undefined';
// The last stored value this session knows of, per folder, so a change to
// one preference keeps the others it doesn't touch.
const known = new Map();
const kv = () => globalThis.puter.kv;
/**
* How to show a folder: what's stored for it, then what its fsentry carries
* (the server kept a layout there before), then the defaults.
*
* @param {object | null | undefined} fsentry - from `puter.fs.stat()`
* @returns {Promise<{ sort_by: string, sort_order: string, layout: string }>}
*/
export async function getFolderView(fsentry) {
const uid = fsentry?.uid ?? fsentry?.id;
let stored = {};
if ( usableUid(uid) ) {
const read = kv().get(folderViewKey(uid)).then(pickFolderView);
known.set(uid, read.catch(() => ({})));
try {
stored = await read;
} catch ( err ) {
console.warn('Could not load folder view:', err);
}
}
return { ...DEFAULT_FOLDER_VIEW, ...pickFolderView(fsentry), ...stored };
}
/**
* Store some of a folder's preferences; the rest stay as they were.
*
* @param {string} uid
* @param {{ sort_by?: string, sort_order?: string, layout?: string }} changes
* @returns {Promise<void>}
*/
export function setFolderView(uid, changes) {
if ( ! usableUid(uid) ) return Promise.resolve();
const base = known.get(uid)
?? kv().get(folderViewKey(uid)).then(pickFolderView).catch(() => ({}));
const next = base.then(stored => ({ ...stored, ...pickFolderView(changes) }));
known.set(uid, next);
return next
.then(value => kv().set(folderViewKey(uid), value))
.catch(err => console.warn('Could not save folder view:', err));
}
/** Forget what this session knows of stored values; for tests. */
export function resetFolderViewCache() {
known.clear();
}
+127
View File
@@ -0,0 +1,127 @@
/*
* 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_FOLDER_VIEW,
folderViewKey,
getFolderView,
pickFolderView,
resetFolderViewCache,
setFolderView,
} from './folderViewPrefs.js';
let store;
let kv;
beforeEach(() => {
store = new Map();
kv = {
get: vi.fn(async key => store.get(key) ?? null),
set: vi.fn(async (key, value) => {
store.set(key, value);
return true;
}),
};
vi.stubGlobal('puter', { kv });
resetFolderViewCache();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('pickFolderView', () => {
it('keeps known values only', () => {
expect(pickFolderView({
sort_by: 'size',
sort_order: 'sideways',
layout: 'details',
extra: 1,
})).toEqual({ sort_by: 'size', layout: 'details' });
});
it('reads a JSON string and ignores anything else', () => {
expect(pickFolderView('{"sort_order":"desc"}')).toEqual({ sort_order: 'desc' });
expect(pickFolderView('nope')).toEqual({});
expect(pickFolderView(null)).toEqual({});
expect(pickFolderView(7)).toEqual({});
});
});
describe('getFolderView', () => {
it('prefers what is stored, then the fsentry, then the defaults', async () => {
store.set(folderViewKey('dir'), { sort_by: 'modified' });
expect(await getFolderView({ uid: 'dir', layout: 'list', sort_by: 'size' })).toEqual({
sort_by: 'modified',
sort_order: 'asc',
layout: 'list',
});
});
it('falls back when the store can\'t be read', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
kv.get.mockRejectedValue(new Error('offline'));
expect(await getFolderView({ id: 'dir', layout: 'details' }))
.toEqual({ ...DEFAULT_FOLDER_VIEW, layout: 'details' });
});
it('doesn\'t ask the store about a folder without a uid', async () => {
expect(await getFolderView({ uid: 'null' })).toEqual(DEFAULT_FOLDER_VIEW);
expect(await getFolderView(undefined)).toEqual(DEFAULT_FOLDER_VIEW);
expect(kv.get).not.toHaveBeenCalled();
});
});
describe('setFolderView', () => {
it('keeps the preferences a change doesn\'t touch', async () => {
store.set(folderViewKey('dir'), { layout: 'details', sort_by: 'size' });
await setFolderView('dir', { sort_by: 'name', sort_order: 'desc' });
expect(store.get(folderViewKey('dir'))).toEqual({
layout: 'details',
sort_by: 'name',
sort_order: 'desc',
});
});
it('builds on the last change, not on a read that predates it', async () => {
await getFolderView({ uid: 'dir' });
const sort = setFolderView('dir', { sort_by: 'type' });
const layout = setFolderView('dir', { layout: 'list' });
await Promise.all([sort, layout]);
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'type', layout: 'list' });
expect(kv.get).toHaveBeenCalledOnce();
});
it('drops unknown values and folders without a uid', async () => {
await setFolderView('dir', { layout: 'grid', sort_by: 'size' });
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'size' });
await setFolderView('null', { layout: 'list' });
await setFolderView(undefined, { layout: 'list' });
expect(kv.set).toHaveBeenCalledOnce();
});
it('reports a failed save instead of throwing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
kv.set.mockRejectedValue(new Error('offline'));
await expect(setFolderView('dir', { layout: 'list' })).resolves.toBeUndefined();
expect(warn).toHaveBeenCalled();
});
});
+22 -14
View File
@@ -23,6 +23,7 @@ import item_icon from './itemIcon.js';
import list_all_shared from './listAllShared.js';
import parse_item_metadata from './parseItemMetadata.js';
import { remember_shared_roots } from './sharedAccess.js';
import { getFolderView } from './folderViewPrefs.js';
const refresh_item_container = function (el_item_container, options) {
// start a transaction
@@ -105,9 +106,6 @@ const refresh_item_container = function (el_item_container, options) {
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
// data-name
$(el_window).attr('data-name', fsentry.name);
// data-path
@@ -115,17 +113,27 @@ const refresh_item_container = function (el_item_container, options) {
$(el_window).find('.window-navbar-path-input').val(container_path);
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
}
$(el_item_container).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_item_container).attr('data-sort_order', fsentry.sort_order ?? 'asc');
// update layout
if ( el_window && el_window.length > 0 )
{
window.update_window_layout(el_window, fsentry.layout);
}
//
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
return getFolderView(fsentry).then(view => {
if ( ! is_current() ) {
return;
}
// Items may already be in, placed by the sort the container had before.
const resort = $(el_item_container).attr('data-sort_by') !== view.sort_by
|| $(el_item_container).attr('data-sort_order') !== view.sort_order;
$(el_item_container).attr('data-sort_by', view.sort_by);
$(el_item_container).attr('data-sort_order', view.sort_order);
if ( el_window && el_window.length > 0 ) {
$(el_window).attr('data-sort_by', view.sort_by);
$(el_window).attr('data-sort_order', view.sort_order);
window.update_window_layout(el_window, view.layout);
if ( view.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, view.sort_by, view.sort_order);
}
}
if ( resort ) {
window.sort_items(el_item_container, view.sort_by, view.sort_order);
}
});
}).catch(() => {
// The listing request below reports the failure to the user.
});
@@ -229,3 +229,57 @@ describe('refresh_item_container after the window moves to another folder', () =
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
});
});
describe('refresh_item_container and the folder\'s stored view', () => {
let views;
beforeEach(() => {
views = {};
puter.kv = {
get: vi.fn((key) => {
views[key] ??= deferred();
return views[key].promise;
}),
};
puter.fs.stat.mockImplementation(async ({ path }) => ({ id: path.slice(1), name: path.slice(1) }));
puter.fs.readdir.mockResolvedValue([]);
vi.spyOn(window, 'sort_items');
vi.spyOn(window, 'update_window_layout');
document.body.innerHTML =
'<div class="window"><div class="item-container" data-sort_by="name" data-sort_order="asc"></div></div>';
});
afterEach(() => {
delete puter.kv;
});
it('lays out and re-sorts the folder by what is stored for it', async () => {
navigate('/docs');
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
views['folder_view:docs'].resolve({ sort_by: 'size', sort_order: 'desc', layout: 'details' });
await vi.waitFor(() => expect(window.sort_items).toHaveBeenCalledWith(
expect.anything(), 'size', 'desc',
));
expect($('.item-container').attr('data-sort_by')).toBe('size');
expect($('.window').attr('data-sort_order')).toBe('desc');
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'details');
});
it('ignores the view of a folder the window has left', async () => {
navigate('/docs');
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
navigate('/pics');
await vi.waitFor(() => expect(views['folder_view:pics']).toBeDefined());
views['folder_view:pics'].resolve(null);
views['folder_view:docs'].resolve({ sort_by: 'size', layout: 'list' });
await vi.waitFor(() => expect(window.update_window_layout).toHaveBeenCalledOnce());
await new Promise((r) => setTimeout(r, 0));
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'icons');
expect(window.update_window_layout).not.toHaveBeenCalledWith(expect.anything(), 'list');
expect($('.item-container').attr('data-sort_by')).toBe('name');
expect(window.sort_items).not.toHaveBeenCalledWith(expect.anything(), 'size', expect.anything());
});
});