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
Nariman JelvehandClaude Opus 5.5 5b1b5b8a7a fix(puter-js): apps launched from desktop.puter.com take its messages (#4640)
In an app, puter.js trusted only https://puter.com as the GUI origin, so
every desktop message from desktop.puter.com failed the origin check and
was dropped. The desktop waits for the app's answer to windowWillClose,
so app windows there never closed; itemsOpened, drag-and-drop, focus,
theme and locale changes were lost the same way, and a reauth request
was posted to the wrong origin.

An app now takes its GUI origin from the launch URL's `puter.origin`,
but only when that is one of Puter's own desktops (exactly
https://puter.com or https://desktop.puter.com); anything else keeps
https://puter.com, so a page that frames an app still can't drive it.
An explicit PUTER_ORIGIN still wins, and a top-level page never reads it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 20:51:33 -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());
});
});