Skip to content

Commit

Permalink
Merge pull request #7964 from sagemathinc/bookmark-stars
Browse files Browse the repository at this point in the history
bookmarks: starting with starred files
  • Loading branch information
williamstein authored Oct 29, 2024
2 parents 99c1956 + 933e510 commit fb26e76
Show file tree
Hide file tree
Showing 24 changed files with 924 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ export function ProjectsWithLicenses({}) {
}
}, []);

function sanitize(s: any): string {
return typeof s === "string" ? s : "";
}

function row_renderer({ index }) {
const { project_id, last_edited, num_licenses } = projects[index];
const project_title = sanitize(project_map?.getIn([project_id, "title"]));
return (
<Row
key={projects[index]?.project_id}
Expand All @@ -45,9 +50,7 @@ export function ProjectsWithLicenses({}) {
}}
>
<Col span={12} style={{ paddingLeft: "15px" }}>
<a>
{trunc_middle(project_map?.getIn([project_id, "title"]) ?? "", 80)}
</a>
<a>{trunc_middle(project_title, 80)}</a>
</Col>
<Col span={6}>
{num_licenses} {plural(num_licenses, "License")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,7 @@ export function FrameTitleBar(props: FrameTitleBarProps) {
);

if (props.title == null && is_active) {
return label;
return <>{label}</>;
}

const body = (
Expand Down
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
119 changes: 119 additions & 0 deletions src/packages/frontend/project/page/flyouts/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* 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_FILES } from "@cocalc/util/consts/bookmarks";
import {
GetStarredBookmarks,
GetStarredBookmarksPayload,
SetStarredBookmarks,
} 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(stars: string[]) {
try {
const payload: SetStarredBookmarks = {
type: STARRED_FILES,
project_id,
stars,
};
await api("bookmarks/set", payload);
} 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 payload: GetStarredBookmarksPayload = {
type: STARRED_FILES,
project_id,
};
const data: GetStarredBookmarks = await api("bookmarks/get", payload);

const { type, status } = data;

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

if (status === "success") {
const { stars } = data;
if (
Array.isArray(stars) &&
stars.every((x) => typeof x === "string")
) {
stars.sort(); // sorted for the xor check below
const next = sortBy(uniq(merge(starred, stars)));
setStarredLS(next);
if (xor(stars, 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", stars);
}
} 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
63 changes: 63 additions & 0 deletions src/packages/next/lib/api/bookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
* License: MS-RSL – see LICENSE.md for details
*/

import isCollaborator from "@cocalc/server/projects/is-collaborator";
import getAccountId from "lib/account/get-account";
import getParams from "lib/api/get-params";

import {
BookmarkGetInputSchema,
BookmarkSetInputSchema,
} from "lib/api/schema/bookmarks";

// Process a request for the api/v2/bookmarks/* endpoints

// TODO: deduplicate this with proper typing

export async function processSetRequest(req) {
// ATTN: very confusing: this is the account_id or project_id for project level API keys
// Since bookmakrs are account specific (and collaborators shouldn't snoop on others), we block project keys
// In the future, there might be project-wide stars, which are not account specific.
const account_id = await getAccountId(req);
if (!account_id) {
throw Error("must be signed in");
}

const data = BookmarkSetInputSchema.parse(getParams(req));

if (account_id === data.project_id) {
throw new Error(
`As of now, you cannot use a project-level API key to modify account specific bookmarks. Use the account level API key!`,
);
}

if (!(await isCollaborator({ account_id, project_id: data.project_id }))) {
throw Error("user must be a collaborator on the project");
}

return { ...data, account_id };
}

export async function processGetRequest(req) {
// ATTN: very confusing: this is the account_id or project_id for project level API keys
const account_id = await getAccountId(req);
if (!account_id) {
throw Error("must be signed in");
}

const data = BookmarkGetInputSchema.parse(getParams(req));

if (account_id === data.project_id) {
throw new Error(
`As of now, you cannot use a project-level API key to modify account specific bookmarks. Use the account level API key!`,
);
}

if (!(await isCollaborator({ account_id, project_id: data.project_id }))) {
throw Error("user must be a collaborator on the project");
}

return { ...data, account_id };
}
Loading

0 comments on commit fb26e76

Please sign in to comment.