diff --git a/src/packages/frontend/cspell.json b/src/packages/frontend/cspell.json index c05fa07b76e..f393183b970 100644 --- a/src/packages/frontend/cspell.json +++ b/src/packages/frontend/cspell.json @@ -89,7 +89,10 @@ "immutablejs", "ipynb", "isabs", + "isactive", "isdir", + "isopen", + "issymlink", "kernelspec", "LLM", "LLMs", diff --git a/src/packages/frontend/project/context.tsx b/src/packages/frontend/project/context.tsx index abdd8bf83c2..2d0d18b6552 100644 --- a/src/packages/frontend/project/context.tsx +++ b/src/packages/frontend/project/context.tsx @@ -3,7 +3,15 @@ * License: MS-RSL – see LICENSE.md for details */ -import { Context, createContext, useContext, useMemo, useState } from "react"; +import { + Context, + createContext, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import * as immutable from "immutable"; import { ProjectActions, @@ -120,6 +128,15 @@ export function useProjectContextProvider({ // not each time the active tab is opened! const manageStarredFiles = useStarredFilesManager(project_id); + // Sync starred files from conat to Redux store for use in computed values + useEffect(() => { + if (actions) { + actions.setState({ + starred_files: immutable.List(manageStarredFiles.starred), + }); + } + }, [manageStarredFiles.starred, actions]); + const kucalc = useTypedRedux("customize", "kucalc"); const onCoCalcCom = kucalc === KUCALC_COCALC_COM; const onCoCalcDocker = kucalc === KUCALC_DISABLED; diff --git a/src/packages/frontend/project/explorer/file-listing/file-listing.tsx b/src/packages/frontend/project/explorer/file-listing/file-listing.tsx index 570cb55d9be..cbfef7d07e8 100644 --- a/src/packages/frontend/project/explorer/file-listing/file-listing.tsx +++ b/src/packages/frontend/project/explorer/file-listing/file-listing.tsx @@ -5,8 +5,6 @@ // Show a file listing. -// cSpell:ignore issymlink - import { Alert, Spin } from "antd"; import * as immutable from "immutable"; import React, { useEffect, useRef, useState } from "react"; @@ -26,6 +24,7 @@ import useVirtuosoScrollHook from "@cocalc/frontend/components/virtuoso-scroll-h import { WATCH_THROTTLE_MS } from "@cocalc/frontend/conat/listings"; import { ProjectActions } from "@cocalc/frontend/project_actions"; import { MainConfiguration } from "@cocalc/frontend/project_configuration"; +import { useStarredFilesManager } from "@cocalc/frontend/project/page/flyouts/store"; import * as misc from "@cocalc/util/misc"; import { FileRow } from "./file-row"; import { ListingHeader } from "./listing-header"; @@ -87,6 +86,7 @@ export const FileListing: React.FC = ({ isRunning, }: Props) => { const [starting, setStarting] = useState(false); + const { starred, setStarredPath } = useStarredFilesManager(project_id); const prev_current_path = usePrevious(current_path); @@ -136,6 +136,10 @@ export const FileListing: React.FC = ({ const checked = checked_files.has(misc.path_to_file(current_path, name)); const color = misc.rowBackground({ index, checked }); const { is_public } = file_map[name]; + const fullPath = misc.path_to_file(current_path, name); + // For directories, add trailing slash to match flyout convention + const pathForStar = isdir ? `${fullPath}/` : fullPath; + const isStarred = starred.includes(pathForStar); return ( = ({ no_select={shift_is_down} link_target={link_target} computeServerId={computeServerId} + isStarred={isStarred} + onToggleStar={(path, starState) => { + // For directories, ensure trailing slash + const normalizedPath = + isdir && !path.endsWith("/") ? `${path}/` : path; + setStarredPath(normalizedPath, starState); + }} /> ); } diff --git a/src/packages/frontend/project/explorer/file-listing/file-row.tsx b/src/packages/frontend/project/explorer/file-listing/file-row.tsx index ca95d8258da..8e3950e344a 100644 --- a/src/packages/frontend/project/explorer/file-listing/file-row.tsx +++ b/src/packages/frontend/project/explorer/file-listing/file-row.tsx @@ -55,6 +55,8 @@ interface Props { // if given, include a little 'server' tag in this color, and tooltip etc using id. // Also important for download and preview links! computeServerId?: number; + isStarred?: boolean; + onToggleStar?: (path: string, starred: boolean) => void; } export const FileRow: React.FC = React.memo((props) => { @@ -176,6 +178,29 @@ export const FileRow: React.FC = React.memo((props) => { } } + function render_star() { + if (!props.onToggleStar) return null; + const path = full_path(); + const starred = props.isStarred ?? false; + const iconName = starred ? "star-filled" : "star"; + + return ( + { + e?.preventDefault(); + e?.stopPropagation(); + props.onToggleStar?.(path, !starred); + }} + style={{ + cursor: "pointer", + fontSize: "14pt", + color: starred ? COLORS.STAR : COLORS.GRAY_L, + }} + /> + ); + } + function full_path() { return misc.path_to_file(props.current_path, props.name); } @@ -235,12 +260,12 @@ export const FileRow: React.FC = React.memo((props) => { return ( ); } catch (error) { return ( -
+
Invalid Date Time
); @@ -365,6 +390,9 @@ export const FileRow: React.FC = React.memo((props) => { {render_icon()} + + {render_star()} + @@ -372,7 +400,7 @@ export const FileRow: React.FC = React.memo((props) => { {render_name()} = React.memo((props) => { ) : ( - + {render_download_button(url)} {render_view_button(url, props.name)} diff --git a/src/packages/frontend/project/explorer/file-listing/listing-header.tsx b/src/packages/frontend/project/explorer/file-listing/listing-header.tsx index 7d40cd5ee50..86128bf8882 100644 --- a/src/packages/frontend/project/explorer/file-listing/listing-header.tsx +++ b/src/packages/frontend/project/explorer/file-listing/listing-header.tsx @@ -6,6 +6,7 @@ import React from "react"; import { TypedMap } from "@cocalc/frontend/app-framework"; import { Icon, Gap, VisibleMDLG } from "@cocalc/frontend/components"; +import { COLORS } from "@cocalc/util/theme"; import { Col, Row } from "antd"; // TODO: Flatten active_file_sort for easy PureComponent use @@ -16,7 +17,7 @@ interface Props { const row_style: React.CSSProperties = { cursor: "pointer", - color: "#666", + color: COLORS.GRAY_M, backgroundColor: "#fafafa", border: "1px solid #eee", borderRadius: "4px", @@ -31,7 +32,7 @@ export const ListingHeader: React.FC = (props: Props) => { function render_sort_link( column_name: string, - display_name: string, + display_name: string | React.JSX.Element, marginLeft?, ) { return ( @@ -45,7 +46,11 @@ export const ListingHeader: React.FC = (props: Props) => { e.preventDefault(); return sort_by(column_name); }} - style={{ color: "#428bca", fontWeight: "bold" }} + style={{ + color: COLORS.FG_BLUE, + fontWeight: "bold", + whiteSpace: "nowrap", + }} > {display_name} @@ -70,10 +75,20 @@ export const ListingHeader: React.FC = (props: Props) => { {render_sort_link("type", "Type", "-4px")} + + {render_sort_link( + "starred", + , + "0px", + )} + {render_sort_link("name", "Name", "-4px")} - + {render_sort_link("time", "Date Modified", "2px")} {render_sort_link("size", "Size/Download/View")} diff --git a/src/packages/frontend/project/page/flyouts/file-list-item.tsx b/src/packages/frontend/project/page/flyouts/file-list-item.tsx index 348ab4dd129..1c10882b918 100644 --- a/src/packages/frontend/project/page/flyouts/file-list-item.tsx +++ b/src/packages/frontend/project/page/flyouts/file-list-item.tsx @@ -52,12 +52,12 @@ const FILE_ITEM_OPENED_STYLE: CSS = { const FILE_ITEM_ACTIVE_STYLE: CSS = { ...FILE_ITEM_OPENED_STYLE, color: COLORS.PROJECT.FIXED_LEFT_OPENED, -}; +} as const; const FILE_ITEM_ACTIVE_STYLE_2: CSS = { ...FILE_ITEM_ACTIVE_STYLE, backgroundColor: COLORS.GRAY_L0, -}; +} as const; const FILE_ITEM_STYLE: CSS = { flex: "1", @@ -105,7 +105,7 @@ const CLOSE_ICON_STYLE: CSS = { top: "1px", position: "relative", paddingBottom: "1px", -}; +} as const; interface Item { isopen?: boolean; @@ -305,12 +305,23 @@ export const FileListItem = React.memo((props: Readonly) => { const icon: IconName = isStarred ? "star-filled" : "star"; + // In "files" mode, always show yellow star when starred + // In "active" mode, only show yellow star when file is also open + const starColor = + mode === "files" + ? isStarred + ? COLORS.STAR + : COLORS.GRAY_L + : isStarred && item.isopen + ? COLORS.STAR + : COLORS.GRAY_L; + return ( { e?.stopPropagation(); diff --git a/src/packages/frontend/project/page/flyouts/files-header.tsx b/src/packages/frontend/project/page/flyouts/files-header.tsx index 050b320a217..8f62f9c621f 100644 --- a/src/packages/frontend/project/page/flyouts/files-header.tsx +++ b/src/packages/frontend/project/page/flyouts/files-header.tsx @@ -218,7 +218,10 @@ export function FilesHeader(props: Readonly): React.JSX.Element { ); } - function renderSortButton(name: string, display: string): React.JSX.Element { + function renderSortButton( + name: string, + display: string | React.JSX.Element, + ): React.JSX.Element { const isActive = activeFileSort.get("column_name") === name; const direction = isActive ? ( ): React.JSX.Element { start this project."} - description={"to update the outdated information in a file directory listing of a project"} + description={ + "to update the outdated information in a file directory listing of a project" + } values={{ A: (c) => ( ): React.JSX.Element { }} > + {renderSortButton( + "starred", + , + )} {renderSortButton("name", "Name")} {renderSortButton("size", "Size")} {renderSortButton("time", "Time")} diff --git a/src/packages/frontend/project/page/flyouts/files.tsx b/src/packages/frontend/project/page/flyouts/files.tsx index aca83cbf658..77320bf5ecd 100644 --- a/src/packages/frontend/project/page/flyouts/files.tsx +++ b/src/packages/frontend/project/page/flyouts/files.tsx @@ -99,6 +99,7 @@ export function FilesFlyout({ isRunning: projectIsRunning, project_id, actions, + manageStarredFiles, } = useProjectContext(); const isMountedRef = useIsMountedRef(); const rootRef = useRef(null as any); @@ -239,6 +240,21 @@ export function FilesFlyout({ const aExt = a.name.split(".").pop() ?? ""; const bExt = b.name.split(".").pop() ?? ""; return aExt.localeCompare(bExt); + case "starred": + const pathA = path_to_file(current_path, a.name); + const pathB = path_to_file(current_path, b.name); + const starPathA = a.isdir ? `${pathA}/` : pathA; + const starPathB = b.isdir ? `${pathB}/` : pathB; + const starredA = manageStarredFiles.starred.includes(starPathA); + const starredB = manageStarredFiles.starred.includes(starPathB); + + if (starredA && !starredB) { + return -1; + } else if (!starredA && starredB) { + return 1; + } else { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + } default: console.warn(`flyout/files: unknown sort column ${col}`); return 0; @@ -257,7 +273,7 @@ export function FilesFlyout({ } if (activeFileSort.get("is_descending")) { - procFiles.reverse(); // inplace op + procFiles.reverse(); // in-place op } const isEmpty = procFiles.length === 0; @@ -451,7 +467,7 @@ export function FilesFlyout({ window.getSelection()?.removeAllRanges(); const file = directoryFiles[index]; - // doubleclick straight to open file + // double click straight to open file if (e.detail === 2) { setPrevSelected(index); open(e, index); @@ -578,6 +594,9 @@ export function FilesFlyout({ : checked_files.includes( path_to_file(current_path, directoryFiles[index].name), ); + const fullPath = path_to_file(current_path, item.name); + const pathForStar = item.isdir ? `${fullPath}/` : fullPath; + const isStarred = manageStarredFiles.starred.includes(pathForStar); return ( { + const normalizedPath = + item.isdir && !fullPath.endsWith("/") ? `${fullPath}/` : fullPath; + manageStarredFiles.setStarredPath(normalizedPath, starState); + }} /> ); } diff --git a/src/packages/frontend/project_store.ts b/src/packages/frontend/project_store.ts index 3b06e820c6e..d64c7b529e7 100644 --- a/src/packages/frontend/project_store.ts +++ b/src/packages/frontend/project_store.ts @@ -99,6 +99,7 @@ export interface ProjectStoreState { activity: any; // immutable, active_file_sort: TypedMap<{ column_name: string; is_descending: boolean }>; page_number: number; + starred_files?: immutable.List; // paths to starred files (synced from conat) file_action?: string; // undefineds is meaningfully none here file_search?: string; show_hidden?: boolean; @@ -384,6 +385,7 @@ export class ProjectStore extends Store { "show_hidden", "show_masked", "compute_server_id", + "starred_files", ] as const, fn: () => { const search_escape_char = "/"; @@ -445,7 +447,8 @@ export class ProjectStore extends Store { } const sorter = (() => { - switch (this.get("active_file_sort").get("column_name")) { + const active_col = this.get("active_file_sort").get("column_name"); + switch (active_col) { case "name": return _sort_on_string_field("name"); case "time": @@ -465,6 +468,32 @@ export class ProjectStore extends Store { ); } }; + case "starred": + return (a, b) => { + const starredFiles = this.get("starred_files"); + if (!starredFiles) return 0; + + const pathA = misc.path_to_file( + this.get("current_path"), + a.name, + ); + const pathB = misc.path_to_file( + this.get("current_path"), + b.name, + ); + const starPathA = a.isdir ? `${pathA}/` : pathA; + const starPathB = b.isdir ? `${pathB}/` : pathB; + const starredA = starredFiles.includes(starPathA); + const starredB = starredFiles.includes(starPathB); + + if (starredA && !starredB) { + return -1; + } else if (!starredA && starredB) { + return 1; + } else { + return misc.cmp(a.name.toLowerCase(), b.name.toLowerCase()); + } + }; } })();