Skip to content

Commit 329e626

Browse files
authored
Renameable folders (#814)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> Fixes #615 Adds the ability to right click on a folder to rename it. #### Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents 604ad29 + c8f6c1b commit 329e626

3 files changed

Lines changed: 230 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Added the ability to right click on a folder to rename it.

src/app/hooks/useSidebarItems.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ export const useSidebarItems = (
102102
return [sidebarItems, setSidebarItems];
103103
};
104104

105+
export const renameSidebarFolderItem = (
106+
items: SidebarItems,
107+
folderId: string,
108+
name: string | undefined
109+
): SidebarItems => {
110+
const trimmed = name?.trim();
111+
const nextName = trimmed ? trimmed : undefined;
112+
return items.map((item) =>
113+
typeof item === 'object' && item.id === folderId ? { ...item, name: nextName } : item
114+
);
115+
};
116+
105117
export const sidebarItemWithout = (items: SidebarItems, roomId: string) => {
106118
const newItems: SidebarItems = items
107119
.map((item) => {

src/app/pages/client/sidebar/SpaceTabs.tsx

Lines changed: 213 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
1-
import type { MouseEventHandler, ReactNode, RefObject } from 'react';
1+
import type { FormEventHandler, MouseEventHandler, ReactNode, RefObject, ChangeEvent } from 'react';
22
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
import { useNavigate } from 'react-router-dom';
44
import type { RectCords } from 'folds';
55
import {
66
Box,
7+
Button,
8+
Dialog,
9+
Header,
710
Icon,
811
IconButton,
912
Icons,
13+
Input,
1014
Line,
1115
Menu,
1216
MenuItem,
17+
Overlay,
18+
OverlayBackdrop,
19+
OverlayCenter,
1320
PopOut,
1421
Text,
1522
config,
1623
toRem,
1724
} from 'folds';
1825
import { useAtom, useAtomValue } from 'jotai';
19-
import type { Room } from '$types/matrix-sdk';
26+
import type { MatrixClient, Room } from '$types/matrix-sdk';
2027
import {
2128
draggable,
2229
dropTargetForElements,
@@ -58,6 +65,7 @@ import type { ISidebarFolder, SidebarItems, TSidebarItem } from '$hooks/useSideb
5865
import {
5966
makeCinnySpacesContent,
6067
parseSidebar,
68+
renameSidebarFolderItem,
6169
sidebarItemWithout,
6270
useSidebarItems,
6371
} from '$hooks/useSidebarItems';
@@ -213,6 +221,121 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
213221
}
214222
);
215223

224+
type FolderMenuProps = {
225+
requestClose: () => void;
226+
onRename: () => void;
227+
};
228+
const FolderMenu = forwardRef<HTMLDivElement, FolderMenuProps>(
229+
({ requestClose, onRename }, ref) => (
230+
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
231+
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
232+
<MenuItem
233+
size="300"
234+
radii="300"
235+
onClick={() => {
236+
onRename();
237+
requestClose();
238+
}}
239+
after={<Icon size="100" src={Icons.Pencil} />}
240+
>
241+
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
242+
Rename
243+
</Text>
244+
</MenuItem>
245+
</Box>
246+
</Menu>
247+
)
248+
);
249+
250+
const FOLDER_NAME_MAX_LENGTH = 200;
251+
252+
const folderDefaultDisplayName = (mx: MatrixClient, folder: ISidebarFolder): string => {
253+
const auto = folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ');
254+
return (folder.name ?? auto) || 'Unnamed';
255+
};
256+
257+
type RenameFolderDialogProps = {
258+
mx: MatrixClient;
259+
folder: ISidebarFolder;
260+
onClose: () => void;
261+
onSave: (name: string) => void;
262+
};
263+
function RenameFolderDialog({ mx, folder, onClose, onSave }: Readonly<RenameFolderDialogProps>) {
264+
const [draft, setDraft] = useState(() => folderDefaultDisplayName(mx, folder));
265+
266+
useEffect(() => {
267+
setDraft(folderDefaultDisplayName(mx, folder));
268+
}, [mx, folder]);
269+
270+
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
271+
evt.preventDefault();
272+
onSave(draft);
273+
};
274+
275+
return (
276+
<Overlay open backdrop={<OverlayBackdrop />}>
277+
<OverlayCenter>
278+
<FocusTrap
279+
focusTrapOptions={{
280+
initialFocus: false,
281+
clickOutsideDeactivates: true,
282+
onDeactivate: onClose,
283+
escapeDeactivates: stopPropagation,
284+
}}
285+
>
286+
<Dialog variant="Surface">
287+
<Header
288+
style={{
289+
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
290+
borderBottomWidth: config.borderWidth.B300,
291+
}}
292+
variant="Surface"
293+
size="500"
294+
>
295+
<Box grow="Yes">
296+
<Text size="H4">Rename Folder</Text>
297+
</Box>
298+
<IconButton size="300" onClick={onClose} radii="300">
299+
<Icon src={Icons.Cross} />
300+
</IconButton>
301+
</Header>
302+
<Box
303+
as="form"
304+
onSubmit={handleSubmit}
305+
style={{ padding: config.space.S400 }}
306+
direction="Column"
307+
gap="400"
308+
>
309+
<Text priority="400" size="T300">
310+
Choose a short label for this folder. Leave empty to show space names again.
311+
</Text>
312+
<Box direction="Column" gap="100">
313+
<Text size="L400">Folder name</Text>
314+
<Input
315+
name="folderName"
316+
variant="Background"
317+
value={draft}
318+
maxLength={FOLDER_NAME_MAX_LENGTH}
319+
onChange={(e: ChangeEvent<HTMLInputElement>) => setDraft(e.target.value)}
320+
autoFocus
321+
/>
322+
</Box>
323+
<Box direction="Row" gap="200" justifyContent="End">
324+
<Button type="button" variant="Secondary" fill="Soft" onClick={onClose}>
325+
<Text size="B400">Cancel</Text>
326+
</Button>
327+
<Button type="submit" variant="Primary">
328+
<Text size="B400">Save</Text>
329+
</Button>
330+
</Box>
331+
</Box>
332+
</Dialog>
333+
</FocusTrap>
334+
</OverlayCenter>
335+
</Overlay>
336+
);
337+
}
338+
216339
type InstructionType = Instruction['type'];
217340
type FolderDraggable = {
218341
folder: ISidebarFolder;
@@ -495,9 +618,15 @@ function SpaceTab({
495618
type OpenedSpaceFolderProps = {
496619
folder: ISidebarFolder;
497620
onClose: MouseEventHandler<HTMLButtonElement>;
621+
onFolderContextMenu?: MouseEventHandler<HTMLDivElement>;
498622
children?: ReactNode;
499623
};
500-
function OpenedSpaceFolder({ folder, onClose, children }: Readonly<OpenedSpaceFolderProps>) {
624+
function OpenedSpaceFolder({
625+
folder,
626+
onClose,
627+
onFolderContextMenu,
628+
children,
629+
}: Readonly<OpenedSpaceFolderProps>) {
501630
const aboveTargetRef = useRef<HTMLDivElement>(null);
502631
const belowTargetRef = useRef<HTMLDivElement>(null);
503632

@@ -513,7 +642,7 @@ function OpenedSpaceFolder({ folder, onClose, children }: Readonly<OpenedSpaceFo
513642
data-drop-below={orderBelow === 'reorder-below'}
514643
>
515644
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
516-
<SidebarAvatar size="300">
645+
<SidebarAvatar size="300" onContextMenu={onFolderContextMenu}>
517646
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
518647
<Icon size="400" src={Icons.ChevronTop} filled />
519648
</IconButton>
@@ -530,13 +659,15 @@ type ClosedSpaceFolderProps = {
530659
onOpen: MouseEventHandler<HTMLButtonElement>;
531660
onDragging: (dragItem?: SidebarDraggable) => void;
532661
disabled?: boolean;
662+
onFolderContextMenu?: MouseEventHandler<HTMLButtonElement>;
533663
};
534664
function ClosedSpaceFolder({
535665
folder,
536666
selected,
537667
onOpen,
538668
onDragging,
539669
disabled,
670+
onFolderContextMenu,
540671
}: Readonly<ClosedSpaceFolderProps>) {
541672
const mx = useMatrixClient();
542673
const useAuthentication = useMediaAuthentication();
@@ -547,8 +678,7 @@ function ClosedSpaceFolder({
547678
const dropState = useDropTarget(spaceDraggable, handlerRef);
548679
const dropType = dropState?.type;
549680

550-
const tooltipName =
551-
folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed';
681+
const tooltipName = folderDefaultDisplayName(mx, folder);
552682

553683
return (
554684
<RoomsUnreadProvider rooms={folder.content}>
@@ -563,7 +693,13 @@ function ClosedSpaceFolder({
563693
>
564694
<SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
565695
{(tooltipRef) => (
566-
<SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
696+
<SidebarFolder
697+
data-id={folder.id}
698+
as="button"
699+
ref={tooltipRef}
700+
onClick={onOpen}
701+
onContextMenu={onFolderContextMenu}
702+
>
567703
{folder.content.map((sId) => {
568704
const space = mx.getRoom(sId);
569705
if (!space) return null;
@@ -612,6 +748,33 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
612748
const navToActivePath = useAtomValue(useNavToActivePathAtom());
613749
const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
614750
const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
751+
const [folderMenuState, setFolderMenuState] = useState<{
752+
folder: ISidebarFolder;
753+
anchor: RectCords;
754+
}>();
755+
const [renameTargetFolder, setRenameTargetFolder] = useState<ISidebarFolder>();
756+
757+
const handleFolderContextMenu = useCallback(
758+
(folder: ISidebarFolder): MouseEventHandler =>
759+
(evt) => {
760+
evt.preventDefault();
761+
setFolderMenuState({
762+
folder,
763+
anchor: evt.currentTarget.getBoundingClientRect(),
764+
});
765+
},
766+
[]
767+
);
768+
769+
const handleRenameFolderApply = useCallback(
770+
(folderId: string, rawName: string) => {
771+
const newItems = renameSidebarFolderItem(sidebarItems, folderId, rawName);
772+
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
773+
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
774+
mx.setAccountData(CustomAccountDataEvent.CinnySpaces, newSpacesContent);
775+
},
776+
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
777+
);
615778

616779
useDnDMonitor(
617780
scrollRef,
@@ -797,13 +960,54 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
797960
if (sidebarItems.length === 0) return null;
798961
return (
799962
<>
963+
{folderMenuState && (
964+
<PopOut
965+
anchor={folderMenuState.anchor}
966+
position="Right"
967+
align="Start"
968+
content={
969+
<FocusTrap
970+
focusTrapOptions={{
971+
initialFocus: false,
972+
returnFocusOnDeactivate: false,
973+
onDeactivate: () => setFolderMenuState(undefined),
974+
clickOutsideDeactivates: true,
975+
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
976+
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
977+
escapeDeactivates: stopPropagation,
978+
}}
979+
>
980+
<FolderMenu
981+
requestClose={() => setFolderMenuState(undefined)}
982+
onRename={() => setRenameTargetFolder(folderMenuState.folder)}
983+
/>
984+
</FocusTrap>
985+
}
986+
/>
987+
)}
988+
{renameTargetFolder && (
989+
<RenameFolderDialog
990+
mx={mx}
991+
folder={renameTargetFolder}
992+
onClose={() => setRenameTargetFolder(undefined)}
993+
onSave={(name) => {
994+
handleRenameFolderApply(renameTargetFolder.id, name);
995+
setRenameTargetFolder(undefined);
996+
}}
997+
/>
998+
)}
800999
<SidebarStackSeparator />
8011000
<SidebarStack>
8021001
{sidebarItems.map((item) => {
8031002
if (typeof item === 'object') {
8041003
if (openedFolder.has(item.id)) {
8051004
return (
806-
<OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
1005+
<OpenedSpaceFolder
1006+
key={item.id}
1007+
folder={item}
1008+
onClose={handleFolderToggle}
1009+
onFolderContextMenu={handleFolderContextMenu(item)}
1010+
>
8071011
{item.content.map((sId) => {
8081012
const space = mx.getRoom(sId);
8091013
if (!space) return null;
@@ -835,6 +1039,7 @@ export function SpaceTabs({ scrollRef }: Readonly<SpaceTabsProps>) {
8351039
selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
8361040
onOpen={handleFolderToggle}
8371041
onDragging={setDraggingItem}
1042+
onFolderContextMenu={handleFolderContextMenu(item)}
8381043
disabled={
8391044
typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
8401045
}

0 commit comments

Comments
 (0)