Skip to content

Commit

Permalink
bookmarks: for starred files, first idea
Browse files Browse the repository at this point in the history
  • Loading branch information
haraldschilly committed Oct 18, 2024
1 parent a18f271 commit ea5a872
Show file tree
Hide file tree
Showing 18 changed files with 801 additions and 38 deletions.
16 changes: 16 additions & 0 deletions src/packages/frontend/project/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
import { useProjectStatus } from "./page/project-status-hook";
import { useProjectHasInternetAccess } from "./settings/has-internet-access-hook";
import { Project } from "./settings/types";
import { useStarredFilesManager } from "./page/flyouts/store";
import { FlyoutActiveStarred } from "./page/flyouts/state";

export interface ProjectContextState {
actions?: ProjectActions;
Expand All @@ -42,6 +44,10 @@ export interface ProjectContextState {
onCoCalcDocker: boolean;
enabledLLMs: LLMServicesAvailable;
mainWidthPx: number;
manageStarredFiles: {
starred: FlyoutActiveStarred;
setStarredPath: (path: string, starState: boolean) => void;
};
}

export const ProjectContext: Context<ProjectContextState> =
Expand All @@ -68,6 +74,10 @@ export const ProjectContext: Context<ProjectContextState> =
user: false,
},
mainWidthPx: 0,
manageStarredFiles: {
starred: [],
setStarredPath: () => {},
},
});

export function useProjectContext() {
Expand Down Expand Up @@ -105,6 +115,11 @@ export function useProjectContextProvider({
// shared data: used to flip through the open tabs in the active files flyout
const flipTabs = useState<number>(0);

// manage starred files (active tabs)
// This is put here, to only sync the starred files when the project is opened,
// not each time the active tab is opened!
const manageStarredFiles = useStarredFilesManager(project_id);

const kucalc = useTypedRedux("customize", "kucalc");
const onCoCalcCom = kucalc === KUCALC_COCALC_COM;
const onCoCalcDocker = kucalc === KUCALC_DISABLED;
Expand Down Expand Up @@ -145,5 +160,6 @@ export function useProjectContextProvider({
onCoCalcDocker,
enabledLLMs,
mainWidthPx,
manageStarredFiles,
};
}
21 changes: 5 additions & 16 deletions src/packages/frontend/project/page/flyouts/active.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,9 @@ import { FileListItem } from "./file-list-item";
import { FlyoutFilterWarning } from "./filter-warning";
import {
FlyoutActiveMode,
FlyoutActiveStarred,
FlyoutActiveTabSort,
getFlyoutActiveMode,
getFlyoutActiveShowStarred,
getFlyoutActiveStarred,
getFlyoutActiveTabSort,
isFlyoutActiveMode,
storeFlyoutState,
Expand Down Expand Up @@ -119,17 +117,14 @@ interface Props {
export function ActiveFlyout(props: Readonly<Props>): JSX.Element {
const { wrap, flyoutWidth } = props;
const { formatIntl } = useAppContext();
const { project_id, flipTabs } = useProjectContext();
const { project_id, flipTabs, manageStarredFiles } = useProjectContext();
const flipTab = flipTabs[0];
const flipTabPrevious = usePrevious(flipTab);
const actions = useActions({ project_id });

const [mode, setActiveMode] = useState<FlyoutActiveMode>(
getFlyoutActiveMode(project_id),
);
const [starred, setStarred] = useState<FlyoutActiveStarred>(
getFlyoutActiveStarred(project_id),
);

const [sortTabs, setSortTabsState] = useState<FlyoutActiveTabSort>(
getFlyoutActiveTabSort(project_id),
Expand All @@ -144,6 +139,8 @@ export function ActiveFlyout(props: Readonly<Props>): JSX.Element {
);
const [showStarredTabs, setShowStarredTabs] = useState<boolean>(true);

const { starred, setStarredPath } = manageStarredFiles;

function setMode(mode: FlyoutActiveMode) {
if (isFlyoutActiveMode(mode)) {
setActiveMode(mode);
Expand All @@ -153,14 +150,6 @@ export function ActiveFlyout(props: Readonly<Props>): JSX.Element {
}
}

function setStarredPath(path: string, next: boolean) {
const newStarred = next
? [...starred, path]
: starred.filter((p) => p !== path);
setStarred(newStarred);
storeFlyoutState(project_id, "active", { starred: newStarred });
}

function setSortTabs(sort: FlyoutActiveTabSort) {
setSortTabsState(sort);
storeFlyoutState(project_id, "active", { activeTabSort: sort });
Expand Down Expand Up @@ -297,12 +286,12 @@ export function ActiveFlyout(props: Readonly<Props>): JSX.Element {
}
}}
isStarred={showStarred ? starred.includes(path) : undefined}
onStar={(next: boolean) => {
onStar={(starState: boolean) => {
// we only toggle star, if it is currently opeend!
// otherwise, when closed and accidentally clicking on the star
// the file unstarred and just vanishes
if (isopen) {
setStarredPath(path, next);
setStarredPath(path, starState);
} else {
handleFileClick(undefined, path, "star");
}
Expand Down
113 changes: 113 additions & 0 deletions src/packages/frontend/project/page/flyouts/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

import { merge, sortBy, throttle, uniq, xor } from "lodash";
import { useState } from "react";
import useAsyncEffect from "use-async-effect";

import api from "@cocalc/frontend/client/api";
import { STARRED } from "@cocalc/util/consts/bookmarks";
import { GetStarredBookmarks } from "@cocalc/util/types/bookmarks";
import {
FlyoutActiveStarred,
getFlyoutActiveStarred,
storeFlyoutState,
} from "./state";

// Additionally to local storage, we back the state of the starred files in the database.
// Errors with the API are ignored, because we primarily rely on local storage.
// The only really important situation to think of are when there is nothing in local storage but in the database,
// or when there is
export function useStarredFilesManager(project_id: string) {
const [starred, setStarred] = useState<FlyoutActiveStarred>(
getFlyoutActiveStarred(project_id),
);

// once after mounting this, we update the starred bookmarks (which merges with what we have) and then stores it
useAsyncEffect(async () => {
await updateStarred();
}, []);

function setStarredLS(starred: string[]) {
setStarred(starred);
storeFlyoutState(project_id, "active", { starred: starred });
}

// TODO: there are also add/remove API endpoints, but for now we stick with set. Hardly worth optimizing.
function setStarredPath(path: string, starState: boolean) {
const next = starState
? [...starred, path]
: starred.filter((p) => p !== path);
setStarredLS(next);
storeStarred(next);
}

async function storeStarred(starred: string[]) {
try {
await api("bookmarks/set", {
type: STARRED,
project_id,
payload: starred,
});
} catch (err) {
console.error("api error", err);
}
}

// this is called once, when the flyout/tabs component is mounted
// throtteld, to usually take 1 sec from opening the panel to loading the stars
const updateStarred = throttle(
async () => {
try {
const data: GetStarredBookmarks = await api("bookmarks/get", {
type: STARRED,
project_id,
});

const { type, status } = data;

if (type !== STARRED) {
console.error(
`flyout/store/starred type must be ${STARRED} but we got`,
type,
);
return;
}

if (status === "success") {
const { payload } = data;
if (
Array.isArray(payload) &&
payload.every((x) => typeof x === "string")
) {
payload.sort(); // sorted for the xor check below
const next = sortBy(uniq(merge(starred, payload)));
setStarredLS(next);
if (xor(payload, next).length > 0) {
// if there is a change (e.g. nothing in the database stored yet), store the stars
await storeStarred(next);
}
} else {
console.error("flyout/store/starred invalid payload", payload);
}
} else if (status === "error") {
const { error } = data;
console.error("flyout/store/starred error", error);
} else {
console.error("flyout/store/starred error: unknown status", status);
}
} catch (err) {
console.error("api error", err);
}
},
1000,
{ trailing: true, leading: false },
);

return {
starred,
setStarredPath,
};
}
9 changes: 5 additions & 4 deletions src/packages/frontend/projects/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@

import { Set } from "immutable";
import { isEqual } from "lodash";

import { alert_message } from "@cocalc/frontend/alerts";
import { Actions, redux } from "@cocalc/frontend/app-framework";
import { set_window_title } from "@cocalc/frontend/browser";
import api from "@cocalc/frontend/client/api";
import { COCALC_MINIMAL } from "@cocalc/frontend/fullscreen";
import { markdown_to_html } from "@cocalc/frontend/markdown";
import type { FragmentId } from "@cocalc/frontend/misc/fragment-id";
import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle";
import startProjectPayg from "@cocalc/frontend/purchases/pay-as-you-go/start-project";
import { site_license_public_info } from "@cocalc/frontend/site-licenses/util";
import { webapp_client } from "@cocalc/frontend/webapp-client";
import { once } from "@cocalc/util/async-utils";
import type { StudentProjectFunctionality } from "@cocalc/util/db-schema/projects";
import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
import {
assert_uuid,
copy,
Expand All @@ -28,10 +33,6 @@ import { SiteLicenseQuota } from "@cocalc/util/types/site-licenses";
import { Upgrades } from "@cocalc/util/upgrades/types";
import { ProjectsState, store } from "./store";
import { load_all_projects, switch_to_project } from "./table";
import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
import api from "@cocalc/frontend/client/api";
import type { StudentProjectFunctionality } from "@cocalc/util/db-schema/projects";
import startProjectPayg from "@cocalc/frontend/purchases/pay-as-you-go/start-project";

import type {
CourseInfo,
Expand Down
76 changes: 76 additions & 0 deletions src/packages/next/lib/api/schema/bookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

import { z } from "../framework";

import {
LoadStarredFilesBookmarksProps,
SaveStarredFilesBoookmarksProps,
} from "@cocalc/server/bookmarks/starred";
import { STARRED } from "@cocalc/util/consts/bookmarks";
import { GetStarredBookmarks } from "@cocalc/util/types/bookmarks";
import { ProjectIdSchema } from "./projects/common";

const ERROR = z.object({
status: z.literal("error"),
error: z.string(),
});

const COMMON_STARRED = z.object({
project_id: ProjectIdSchema,
type: z.literal(STARRED),
});

export const BookmarkSetInputSchema = COMMON_STARRED.extend({
payload: z.string().array(),
});

export const BookmarkSetOutputSchema = z.union([
COMMON_STARRED.merge(z.object({ status: z.literal("success") })),
ERROR,
]);

export const BookmarkAddInputSchema = BookmarkSetInputSchema;
export const BookmarkAddOutputSchema = BookmarkSetOutputSchema;

export const BookmarkRemoveInputSchema = BookmarkSetInputSchema;
export const BookmarkRemoveOutputSchema = BookmarkSetOutputSchema;

export const BookmarkGetInputSchema = COMMON_STARRED;
export const BookmarkGetOutputSchema = z.union([
z
.object({
status: z.literal("success"),
payload: z
.array(z.string())
.describe(
"Array of file path strings, as they are in the starred tabs flyout",
),
last_edited: z
.number()
.optional()
.describe("UNIX epoch timestamp, when bookmark was last edited"),
})
.merge(COMMON_STARRED),
ERROR.merge(COMMON_STARRED),
]);

export type BookmarkSetInputType = z.infer<typeof BookmarkSetInputSchema>;
export type BookmarkSetOutputType = z.infer<typeof BookmarkSetOutputSchema>;
export type BookmarkAddInputType = z.infer<typeof BookmarkAddInputSchema>;
export type BookmarkAddOutputType = z.infer<typeof BookmarkRemoveOutputSchema>;
export type BookmarkRemoveInputType = z.infer<typeof BookmarkRemoveInputSchema>;
export type BookmarkRemoveOutputType = z.infer<typeof BookmarkAddOutputSchema>;
export type BookmarkGetInputType = z.infer<typeof BookmarkGetInputSchema>;
export type BookmarkGetOutputType = z.infer<typeof BookmarkGetOutputSchema>;

// consistency checks
export const _1: Omit<SaveStarredFilesBoookmarksProps, "mode" | "account_id"> =
{} as Omit<BookmarkSetInputType, typeof STARRED>;

export const _2: Omit<LoadStarredFilesBookmarksProps, "account_id"> =
{} as Omit<BookmarkGetInputType, typeof STARRED>;

export const _3: BookmarkGetOutputType = {} as GetStarredBookmarks;
20 changes: 20 additions & 0 deletions src/packages/next/pages/api/v2/bookmarks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createMocks } from "lib/api/test-framework";
// import get from "./bookmarks/get";
import set from "./bookmarks/set";

describe("/api/v2/bookmarks", () => {
test("set then get", async () => {
const { req, res } = createMocks({
method: "POST",
url: "/api/v2/bookmarks/set",
body: {
type: "starred-files",
payload: { stars: ["foo.md", "bar.ipynb"] },
},
});

await set(req, res);
expect(res.statusCode).toBe(200);
console.log(res._getJSONData());
});
});
Loading

0 comments on commit ea5a872

Please sign in to comment.