Draw an expanded folder as a panel around its servers

An open folder and the servers inside it now share a container with a
hairline border and a ground a shade lighter than the rail, the way
Discord marks one. Without it there was nothing to say where the folder
ended - the contents are deliberately not indented, so a run of tiles
below a folder looked exactly like the rest of the column.

This needed the members to belong to the folder rather than being spliced
into the entry list beside it, which is a better shape anyway: the folder
now carries what is in it and the rail decides whether to draw them.

Collapsed, the container has no border, no ground and no padding - the
folder is just another tile in the column.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
SalastilandClaude Opus 5 committed 2026-08-23 21:21:32 -04:00
1 parent 94a51e9e51
commit e36bc09408
3 files changed
+63 -39

No files matched your search

+19
View File
@@ -2338,6 +2338,25 @@ video.lightbox-media {
/* --- rail folders ------------------------------------------------------ */
/* An expanded folder and its servers share one panel: a hairline border and
a ground a shade lighter than the rail, which is what marks where the
folder ends now that its contents are not indented. Collapsed, the
container is invisible and the folder is just another tile. */
.rail-folder-group {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
width: 100%;
}
.rail-folder-group.open {
padding: var(--spacing-xxs) 0;
background: var(--surface-container-low);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
}
/* Squarer than a server tile, so a folder is not mistaken for one. */
.rail-folder .rail-face {
border-radius: var(--corner-radius);
+40 -30
View File
@@ -269,7 +269,38 @@ export function ServerRail(): JSX.Element | null {
setNaming({ id, name: 'New folder' })
}
const entries = railEntries(ordered, folders, isFolderOpen)
const entries = railEntries(ordered, folders)
const renderTile = (g: RailGroup): JSX.Element => {
const t = totals.get(g.id)
return (
<RailTile
key={g.id}
group={g}
customIcon={customIcons[g.id]}
muted={mutedGroups.includes(g.id)}
onToggleMute={() =>
setMutedGroups(
mutedGroups.includes(g.id)
? mutedGroups.filter((x) => x !== g.id)
: [...mutedGroups, g.id]
)
}
active={g.id === activeGroupId}
unread={t?.unread ?? 0}
highlight={t?.highlight ?? false}
// Direct messages and pinned lead the rail by definition, so
// there is nowhere for them to be dragged to.
draggable={!isFixedEntry(g)}
dropTarget={over === g.id && dragging !== '' && dragging !== g.id}
onSelect={() => store.selectGroup(g.id)}
onDragStart={() => beginDrag(g.id)}
onDragOver={() => !isFixedEntry(g) && setOver(g.id)}
onDrop={() => onDrop(g.id)}
onDragEnd={endDrag}
/>
)
}
return (
<nav className="server-rail" aria-label="Servers">
@@ -294,7 +325,12 @@ export function ServerRail(): JSX.Element | null {
>
{entries.map((entry) => {
if (entry.kind === 'folder') {
const open = isFolderOpen(entry.id)
return (
/* Expanded, the folder and its servers sit in one panel - the
border and lighter ground are what say where the folder ends,
now that its contents are no longer indented. */
<div key={entry.id} className={classes('rail-folder-group', open && 'open')}>
<FolderTile
key={entry.id}
folder={entry.folder}
@@ -310,37 +346,11 @@ export function ServerRail(): JSX.Element | null {
onDrop={() => onDropInFolder(entry.id)}
onDragEnd={endDrag}
/>
{open && entry.members.map((m) => renderTile(m))}
</div>
)
}
const g = entry.group
const t = totals.get(g.id)
return (
<RailTile
key={g.id}
group={g}
customIcon={customIcons[g.id]}
muted={mutedGroups.includes(g.id)}
onToggleMute={() =>
setMutedGroups(
mutedGroups.includes(g.id)
? mutedGroups.filter((x) => x !== g.id)
: [...mutedGroups, g.id]
)
}
active={g.id === activeGroupId}
unread={t?.unread ?? 0}
highlight={t?.highlight ?? false}
// Direct messages and pinned lead the rail by definition, so
// there is nowhere for them to be dragged to.
draggable={!isFixedEntry(g)}
dropTarget={over === g.id && dragging !== '' && dragging !== g.id}
onSelect={() => store.selectGroup(g.id)}
onDragStart={() => beginDrag(g.id)}
onDragOver={() => !isFixedEntry(g) && setOver(g.id)}
onDrop={() => onDrop(g.id)}
onDragEnd={endDrag}
/>
)
return renderTile(entry.group)
})}
</div>
+4 -9
View File
@@ -227,13 +227,11 @@ export type RailEntry =
*
* A folder takes the position of its first member, so putting servers into one
* does not also reshuffle the column - the folder appears where the topmost of
* them already was. Members are drawn under it only while it is open.
* them already was. Its members come back attached to it rather than spliced
* into the list, because the rail draws an expanded folder and its servers
* inside one container.
*/
export function railEntries(
ordered: RailGroup[],
folders: RailFolder[],
open: (folderId: string) => boolean
): RailEntry[] {
export function railEntries(ordered: RailGroup[], folders: RailFolder[]): RailEntry[] {
const owner = new Map<string, RailFolder>()
for (const f of folders) {
for (const id of f.members) owner.set(id, f)
@@ -255,9 +253,6 @@ export function railEntries(
.map((id) => ordered.find((x) => x.id === id))
.filter((x): x is RailGroup => !!x)
out.push({ kind: 'folder', id: folder.id, folder, members })
if (open(folder.id)) {
for (const m of members) out.push({ kind: 'group', id: m.id, group: m })
}
}
// A folder with nothing in it still draws, at the foot of the column. A