feat: tab names

This commit is contained in:
Alejandro Gómez
2025-12-19 12:49:29 +01:00
parent 812b719ea0
commit 8f80742ef1
5 changed files with 137 additions and 18 deletions

View File

@@ -3,3 +3,4 @@ node_modules
.claude
*.md
package-lock.json
src/data/nostr-kinds-schema.yaml

View File

@@ -3,7 +3,7 @@ import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { LayoutControls } from "./LayoutControls";
import { useEffect } from "react";
import { useEffect, useState } from "react";
export function TabBar() {
const {
@@ -11,13 +11,50 @@ export function TabBar() {
setActiveWorkspace,
createWorkspace,
createWorkspaceWithNumber,
updateWorkspaceLabel,
} = useGrimoire();
const { workspaces, activeWorkspaceId } = state;
// State for inline label editing
const [editingId, setEditingId] = useState<string | null>(null);
const [editingLabel, setEditingLabel] = useState("");
const handleNewTab = () => {
createWorkspace();
};
// Start editing a workspace label
const startEditing = (workspaceId: string, currentLabel?: string) => {
setEditingId(workspaceId);
setEditingLabel(currentLabel || "");
};
// Save label changes
const saveLabel = () => {
if (editingId) {
updateWorkspaceLabel(editingId, editingLabel);
setEditingId(null);
setEditingLabel("");
}
};
// Cancel editing
const cancelEditing = () => {
setEditingId(null);
setEditingLabel("");
};
// Handle keyboard events in input
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
saveLabel();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEditing();
}
};
// Sort workspaces by number (for both rendering and keyboard shortcuts)
const sortedWorkspaces = Object.values(workspaces).sort(
(a, b) => a.number - b.number,
@@ -62,22 +99,60 @@ export function TabBar() {
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
{/* Left side: Workspace tabs + new workspace button */}
<div className="flex items-center gap-1 flex-nowrap">
{sortedWorkspaces.map((ws) => (
<button
key={ws.id}
onClick={() => setActiveWorkspace(ws.id)}
className={cn(
"px-3 py-1 text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0",
ws.id === activeWorkspaceId
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{ws.label && ws.label.trim()
? `${ws.number} ${ws.label}`
: ws.number}
</button>
))}
{sortedWorkspaces.map((ws) => {
const isEditing = editingId === ws.id;
const isActive = ws.id === activeWorkspaceId;
if (isEditing) {
// Render input field when editing
return (
<div
key={ws.id}
className={cn(
"px-3 py-1 text-xs font-mono rounded flex items-center gap-2 flex-shrink-0",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
onClick={(e) => e.stopPropagation()}
>
<span>{ws.number}</span>
<input
type="text"
value={editingLabel}
onChange={(e) => setEditingLabel(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={saveLabel}
autoFocus
style={{ width: `${Math.max(editingLabel.length, 1)}ch` }}
className="bg-transparent border-0 outline-none focus:outline-none focus:ring-0 p-0 m-0"
/>
</div>
);
}
// Render button when not editing
return (
<button
key={ws.id}
onClick={() => setActiveWorkspace(ws.id)}
onDoubleClick={() => startEditing(ws.id, ws.label)}
className={cn(
"flex items-center gap-2 px-3 py-1 text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<span>{ws.number}</span>
{ws.label && ws.label.trim() && (
<span style={{ width: `${ws.label.trim().length || 0}ch` }}>
{ws.label.trim()}
</span>
)}
</button>
);
})}
<Button
variant="ghost"

View File

@@ -399,3 +399,37 @@ export const applyPresetLayout = (
return state;
}
};
/**
* Updates the label of an existing workspace.
* Labels are user-friendly names that appear alongside workspace numbers.
*/
export const updateWorkspaceLabel = (
state: GrimoireState,
workspaceId: string,
label: string | undefined,
): GrimoireState => {
const workspace = state.workspaces[workspaceId];
if (!workspace) {
return state; // Workspace doesn't exist, return unchanged
}
// Normalize label: trim and treat empty strings as undefined
const normalizedLabel = label?.trim() || undefined;
// If label hasn't changed, return state unchanged (optimization)
if (workspace.label === normalizedLabel) {
return state;
}
return {
...state,
workspaces: {
...state.workspaces,
[workspaceId]: {
...workspace,
label: normalizedLabel,
},
},
};
};

View File

@@ -272,6 +272,12 @@ export const useGrimoire = () => {
[setState],
);
const updateWorkspaceLabel = useCallback(
(workspaceId: string, label: string | undefined) =>
setState((prev) => Logic.updateWorkspaceLabel(prev, workspaceId, label)),
[setState],
);
return {
state,
locale: state.locale || browserLocale,
@@ -288,5 +294,6 @@ export const useGrimoire = () => {
setActiveAccountRelays,
updateLayoutConfig,
applyPresetLayout,
updateWorkspaceLabel,
};
};

View File

@@ -125,7 +125,9 @@
/* Smooth animations for window resizing and repositioning */
/* Only animate during preset application, not manual resize/drag */
body.animating-layout .mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-tile {
body.animating-layout
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-tile {
transition:
width 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
height 150ms cubic-bezier(0.25, 0.1, 0.25, 1),