Skip to content

Commit fb26e76

Browse files
authored
Merge pull request #7964 from sagemathinc/bookmark-stars
bookmarks: starting with starred files
2 parents 99c1956 + 933e510 commit fb26e76

File tree

24 files changed

+924
-70
lines changed

24 files changed

+924
-70
lines changed

src/packages/frontend/account/licenses/projects-with-licenses.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ export function ProjectsWithLicenses({}) {
3434
}
3535
}, []);
3636

37+
function sanitize(s: any): string {
38+
return typeof s === "string" ? s : "";
39+
}
40+
3741
function row_renderer({ index }) {
3842
const { project_id, last_edited, num_licenses } = projects[index];
43+
const project_title = sanitize(project_map?.getIn([project_id, "title"]));
3944
return (
4045
<Row
4146
key={projects[index]?.project_id}
@@ -45,9 +50,7 @@ export function ProjectsWithLicenses({}) {
4550
}}
4651
>
4752
<Col span={12} style={{ paddingLeft: "15px" }}>
48-
<a>
49-
{trunc_middle(project_map?.getIn([project_id, "title"]) ?? "", 80)}
50-
</a>
53+
<a>{trunc_middle(project_title, 80)}</a>
5154
</Col>
5255
<Col span={6}>
5356
{num_licenses} {plural(num_licenses, "License")}

src/packages/frontend/frame-editors/frame-tree/title-bar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -977,7 +977,7 @@ export function FrameTitleBar(props: FrameTitleBarProps) {
977977
);
978978

979979
if (props.title == null && is_active) {
980-
return label;
980+
return <>{label}</>;
981981
}
982982

983983
const body = (

src/packages/frontend/project/context.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
import { useProjectStatus } from "./page/project-status-hook";
2727
import { useProjectHasInternetAccess } from "./settings/has-internet-access-hook";
2828
import { Project } from "./settings/types";
29+
import { useStarredFilesManager } from "./page/flyouts/store";
30+
import { FlyoutActiveStarred } from "./page/flyouts/state";
2931

3032
export interface ProjectContextState {
3133
actions?: ProjectActions;
@@ -42,6 +44,10 @@ export interface ProjectContextState {
4244
onCoCalcDocker: boolean;
4345
enabledLLMs: LLMServicesAvailable;
4446
mainWidthPx: number;
47+
manageStarredFiles: {
48+
starred: FlyoutActiveStarred;
49+
setStarredPath: (path: string, starState: boolean) => void;
50+
};
4551
}
4652

4753
export const ProjectContext: Context<ProjectContextState> =
@@ -68,6 +74,10 @@ export const ProjectContext: Context<ProjectContextState> =
6874
user: false,
6975
},
7076
mainWidthPx: 0,
77+
manageStarredFiles: {
78+
starred: [],
79+
setStarredPath: () => {},
80+
},
7181
});
7282

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

118+
// manage starred files (active tabs)
119+
// This is put here, to only sync the starred files when the project is opened,
120+
// not each time the active tab is opened!
121+
const manageStarredFiles = useStarredFilesManager(project_id);
122+
108123
const kucalc = useTypedRedux("customize", "kucalc");
109124
const onCoCalcCom = kucalc === KUCALC_COCALC_COM;
110125
const onCoCalcDocker = kucalc === KUCALC_DISABLED;
@@ -145,5 +160,6 @@ export function useProjectContextProvider({
145160
onCoCalcDocker,
146161
enabledLLMs,
147162
mainWidthPx,
163+
manageStarredFiles,
148164
};
149165
}

src/packages/frontend/project/page/flyouts/active.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,9 @@ import { FileListItem } from "./file-list-item";
5151
import { FlyoutFilterWarning } from "./filter-warning";
5252
import {
5353
FlyoutActiveMode,
54-
FlyoutActiveStarred,
5554
FlyoutActiveTabSort,
5655
getFlyoutActiveMode,
5756
getFlyoutActiveShowStarred,
58-
getFlyoutActiveStarred,
5957
getFlyoutActiveTabSort,
6058
isFlyoutActiveMode,
6159
storeFlyoutState,
@@ -119,17 +117,14 @@ interface Props {
119117
export function ActiveFlyout(props: Readonly<Props>): JSX.Element {
120118
const { wrap, flyoutWidth } = props;
121119
const { formatIntl } = useAppContext();
122-
const { project_id, flipTabs } = useProjectContext();
120+
const { project_id, flipTabs, manageStarredFiles } = useProjectContext();
123121
const flipTab = flipTabs[0];
124122
const flipTabPrevious = usePrevious(flipTab);
125123
const actions = useActions({ project_id });
126124

127125
const [mode, setActiveMode] = useState<FlyoutActiveMode>(
128126
getFlyoutActiveMode(project_id),
129127
);
130-
const [starred, setStarred] = useState<FlyoutActiveStarred>(
131-
getFlyoutActiveStarred(project_id),
132-
);
133128

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

142+
const { starred, setStarredPath } = manageStarredFiles;
143+
147144
function setMode(mode: FlyoutActiveMode) {
148145
if (isFlyoutActiveMode(mode)) {
149146
setActiveMode(mode);
@@ -153,14 +150,6 @@ export function ActiveFlyout(props: Readonly<Props>): JSX.Element {
153150
}
154151
}
155152

156-
function setStarredPath(path: string, next: boolean) {
157-
const newStarred = next
158-
? [...starred, path]
159-
: starred.filter((p) => p !== path);
160-
setStarred(newStarred);
161-
storeFlyoutState(project_id, "active", { starred: newStarred });
162-
}
163-
164153
function setSortTabs(sort: FlyoutActiveTabSort) {
165154
setSortTabsState(sort);
166155
storeFlyoutState(project_id, "active", { activeTabSort: sort });
@@ -297,12 +286,12 @@ export function ActiveFlyout(props: Readonly<Props>): JSX.Element {
297286
}
298287
}}
299288
isStarred={showStarred ? starred.includes(path) : undefined}
300-
onStar={(next: boolean) => {
289+
onStar={(starState: boolean) => {
301290
// we only toggle star, if it is currently opeend!
302291
// otherwise, when closed and accidentally clicking on the star
303292
// the file unstarred and just vanishes
304293
if (isopen) {
305-
setStarredPath(path, next);
294+
setStarredPath(path, starState);
306295
} else {
307296
handleFileClick(undefined, path, "star");
308297
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { merge, sortBy, throttle, uniq, xor } from "lodash";
7+
import { useState } from "react";
8+
import useAsyncEffect from "use-async-effect";
9+
10+
import api from "@cocalc/frontend/client/api";
11+
import { STARRED_FILES } from "@cocalc/util/consts/bookmarks";
12+
import {
13+
GetStarredBookmarks,
14+
GetStarredBookmarksPayload,
15+
SetStarredBookmarks,
16+
} from "@cocalc/util/types/bookmarks";
17+
import {
18+
FlyoutActiveStarred,
19+
getFlyoutActiveStarred,
20+
storeFlyoutState,
21+
} from "./state";
22+
23+
// Additionally to local storage, we back the state of the starred files in the database.
24+
// Errors with the API are ignored, because we primarily rely on local storage.
25+
// The only really important situation to think of are when there is nothing in local storage but in the database,
26+
// or when there is
27+
export function useStarredFilesManager(project_id: string) {
28+
const [starred, setStarred] = useState<FlyoutActiveStarred>(
29+
getFlyoutActiveStarred(project_id),
30+
);
31+
32+
// once after mounting this, we update the starred bookmarks (which merges with what we have) and then stores it
33+
useAsyncEffect(async () => {
34+
await updateStarred();
35+
}, []);
36+
37+
function setStarredLS(starred: string[]) {
38+
setStarred(starred);
39+
storeFlyoutState(project_id, "active", { starred: starred });
40+
}
41+
42+
// TODO: there are also add/remove API endpoints, but for now we stick with set. Hardly worth optimizing.
43+
function setStarredPath(path: string, starState: boolean) {
44+
const next = starState
45+
? [...starred, path]
46+
: starred.filter((p) => p !== path);
47+
setStarredLS(next);
48+
storeStarred(next);
49+
}
50+
51+
async function storeStarred(stars: string[]) {
52+
try {
53+
const payload: SetStarredBookmarks = {
54+
type: STARRED_FILES,
55+
project_id,
56+
stars,
57+
};
58+
await api("bookmarks/set", payload);
59+
} catch (err) {
60+
console.error("api error", err);
61+
}
62+
}
63+
64+
// this is called once, when the flyout/tabs component is mounted
65+
// throtteld, to usually take 1 sec from opening the panel to loading the stars
66+
const updateStarred = throttle(
67+
async () => {
68+
try {
69+
const payload: GetStarredBookmarksPayload = {
70+
type: STARRED_FILES,
71+
project_id,
72+
};
73+
const data: GetStarredBookmarks = await api("bookmarks/get", payload);
74+
75+
const { type, status } = data;
76+
77+
if (type !== STARRED_FILES) {
78+
console.error(
79+
`flyout/store/starred type must be ${STARRED_FILES} but we got`,
80+
type,
81+
);
82+
return;
83+
}
84+
85+
if (status === "success") {
86+
const { stars } = data;
87+
if (
88+
Array.isArray(stars) &&
89+
stars.every((x) => typeof x === "string")
90+
) {
91+
stars.sort(); // sorted for the xor check below
92+
const next = sortBy(uniq(merge(starred, stars)));
93+
setStarredLS(next);
94+
if (xor(stars, next).length > 0) {
95+
// if there is a change (e.g. nothing in the database stored yet), store the stars
96+
await storeStarred(next);
97+
}
98+
} else {
99+
console.error("flyout/store/starred invalid payload", stars);
100+
}
101+
} else if (status === "error") {
102+
const { error } = data;
103+
console.error("flyout/store/starred error", error);
104+
} else {
105+
console.error("flyout/store/starred error: unknown status", status);
106+
}
107+
} catch (err) {
108+
console.error("api error", err);
109+
}
110+
},
111+
1000,
112+
{ trailing: true, leading: false },
113+
);
114+
115+
return {
116+
starred,
117+
setStarredPath,
118+
};
119+
}

src/packages/frontend/projects/actions.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@
55

66
import { Set } from "immutable";
77
import { isEqual } from "lodash";
8+
89
import { alert_message } from "@cocalc/frontend/alerts";
910
import { Actions, redux } from "@cocalc/frontend/app-framework";
1011
import { set_window_title } from "@cocalc/frontend/browser";
12+
import api from "@cocalc/frontend/client/api";
1113
import { COCALC_MINIMAL } from "@cocalc/frontend/fullscreen";
1214
import { markdown_to_html } from "@cocalc/frontend/markdown";
1315
import type { FragmentId } from "@cocalc/frontend/misc/fragment-id";
1416
import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle";
17+
import startProjectPayg from "@cocalc/frontend/purchases/pay-as-you-go/start-project";
1518
import { site_license_public_info } from "@cocalc/frontend/site-licenses/util";
1619
import { webapp_client } from "@cocalc/frontend/webapp-client";
1720
import { once } from "@cocalc/util/async-utils";
21+
import type { StudentProjectFunctionality } from "@cocalc/util/db-schema/projects";
22+
import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
1823
import {
1924
assert_uuid,
2025
copy,
@@ -28,10 +33,6 @@ import { SiteLicenseQuota } from "@cocalc/util/types/site-licenses";
2833
import { Upgrades } from "@cocalc/util/upgrades/types";
2934
import { ProjectsState, store } from "./store";
3035
import { load_all_projects, switch_to_project } from "./table";
31-
import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
32-
import api from "@cocalc/frontend/client/api";
33-
import type { StudentProjectFunctionality } from "@cocalc/util/db-schema/projects";
34-
import startProjectPayg from "@cocalc/frontend/purchases/pay-as-you-go/start-project";
3536

3637
import type {
3738
CourseInfo,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2024 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import isCollaborator from "@cocalc/server/projects/is-collaborator";
7+
import getAccountId from "lib/account/get-account";
8+
import getParams from "lib/api/get-params";
9+
10+
import {
11+
BookmarkGetInputSchema,
12+
BookmarkSetInputSchema,
13+
} from "lib/api/schema/bookmarks";
14+
15+
// Process a request for the api/v2/bookmarks/* endpoints
16+
17+
// TODO: deduplicate this with proper typing
18+
19+
export async function processSetRequest(req) {
20+
// ATTN: very confusing: this is the account_id or project_id for project level API keys
21+
// Since bookmakrs are account specific (and collaborators shouldn't snoop on others), we block project keys
22+
// In the future, there might be project-wide stars, which are not account specific.
23+
const account_id = await getAccountId(req);
24+
if (!account_id) {
25+
throw Error("must be signed in");
26+
}
27+
28+
const data = BookmarkSetInputSchema.parse(getParams(req));
29+
30+
if (account_id === data.project_id) {
31+
throw new Error(
32+
`As of now, you cannot use a project-level API key to modify account specific bookmarks. Use the account level API key!`,
33+
);
34+
}
35+
36+
if (!(await isCollaborator({ account_id, project_id: data.project_id }))) {
37+
throw Error("user must be a collaborator on the project");
38+
}
39+
40+
return { ...data, account_id };
41+
}
42+
43+
export async function processGetRequest(req) {
44+
// ATTN: very confusing: this is the account_id or project_id for project level API keys
45+
const account_id = await getAccountId(req);
46+
if (!account_id) {
47+
throw Error("must be signed in");
48+
}
49+
50+
const data = BookmarkGetInputSchema.parse(getParams(req));
51+
52+
if (account_id === data.project_id) {
53+
throw new Error(
54+
`As of now, you cannot use a project-level API key to modify account specific bookmarks. Use the account level API key!`,
55+
);
56+
}
57+
58+
if (!(await isCollaborator({ account_id, project_id: data.project_id }))) {
59+
throw Error("user must be a collaborator on the project");
60+
}
61+
62+
return { ...data, account_id };
63+
}

0 commit comments

Comments
 (0)