feat: add pin to sidebar for issues and projects (#653)

* feat: add pin to sidebar for issues and projects

Add per-user pinning of issues and projects to the sidebar for quick access.

- New `pinned_item` table with per-user, per-workspace scoping
- REST API: GET/POST /api/pins, DELETE /api/pins/{type}/{id}, PUT /api/pins/reorder
- Sidebar "Pinned" section between Personal and Workspace nav (hidden when empty)
- Pin/unpin actions in issue and project detail dropdown menus
- Optimistic mutations with WebSocket invalidation for real-time sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add drag-and-drop reordering and visible pin buttons

- Sidebar pinned items now support drag-and-drop reordering via @dnd-kit
- Add visible pin/unpin icon button in issue and project detail headers
- Add useReorderPins mutation with optimistic updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove drag handle and fix page refresh after reorder

- Remove GripVertical drag handle — whole item is now draggable, aligning
  with other sidebar elements
- Prevent link navigation after drag using wasDragged ref
- Remove onSettled invalidation from reorder mutation to prevent
  unnecessary refetch after optimistic update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang
2026-04-10 19:00:25 +08:00
committed by GitHub
parent cce210ed3a
commit 9b62485a86
20 changed files with 785 additions and 3 deletions

View File

@@ -45,6 +45,10 @@ import type {
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
PinnedItem,
CreatePinRequest,
PinnedItemType,
ReorderPinsRequest,
} from "../types";
import { type Logger, noopLogger } from "../logger";
@@ -693,4 +697,27 @@ export class ApiClient {
async deleteProject(id: string): Promise<void> {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
// Pins
async listPins(): Promise<PinnedItem[]> {
return this.fetch("/api/pins");
}
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
return this.fetch("/api/pins", {
method: "POST",
body: JSON.stringify(data),
});
}
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
}
async reorderPins(data: ReorderPinsRequest): Promise<void> {
await this.fetch("/api/pins/reorder", {
method: "PUT",
body: JSON.stringify(data),
});
}
}

View File

@@ -45,6 +45,9 @@
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",
"./projects/config": "./projects/config.ts",
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
"./realtime": "./realtime/index.ts",
"./navigation": "./navigation/index.ts",
"./modals": "./modals/index.ts",

View File

@@ -0,0 +1,2 @@
export { pinKeys, pinListOptions } from "./queries";
export { useCreatePin, useDeletePin, useReorderPins } from "./mutations";

View File

@@ -0,0 +1,65 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { pinKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { PinnedItem, PinnedItemType } from "../types";
export function useCreatePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
api.createPin(data),
onSuccess: (newPin) => {
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? [...old, newPin] : [newPin],
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
export function useDeletePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
api.deletePin(itemType, itemId),
onMutate: async ({ itemType, itemId }) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
export function useReorderPins() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (reorderedPins: PinnedItem[]) => {
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
return api.reorderPins({ items });
},
onMutate: async (reorderedPins) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
});
}

View File

@@ -0,0 +1,14 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const pinKeys = {
all: (wsId: string) => ["pins", wsId] as const,
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
};
export function pinListOptions(wsId: string) {
return queryOptions({
queryKey: pinKeys.list(wsId),
queryFn: () => api.listPins(),
});
}

View File

@@ -11,6 +11,7 @@ import { clearWorkspaceStorage } from "../platform/storage-cleanup";
import { defaultStorage } from "../platform/storage";
import { issueKeys } from "../issues/queries";
import { projectKeys } from "../projects/queries";
import { pinKeys } from "../pins/queries";
import { runtimeKeys } from "../runtimes/queries";
import {
onIssueCreated,
@@ -99,6 +100,10 @@ export function useRealtimeSync(
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });

View File

@@ -50,7 +50,9 @@ export type WSEventType =
| "chat:done"
| "project:created"
| "project:updated"
| "project:deleted";
| "project:deleted"
| "pin:created"
| "pin:deleted";
export interface WSMessage<T = unknown> {
type: WSEventType;

View File

@@ -33,3 +33,4 @@ export type { Attachment } from "./attachment";
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";

View File

@@ -0,0 +1,24 @@
export type PinnedItemType = "issue" | "project";
export interface PinnedItem {
id: string;
workspace_id: string;
user_id: string;
item_type: PinnedItemType;
item_id: string;
position: number;
created_at: string;
title: string;
identifier?: string;
icon?: string;
status?: string;
}
export interface CreatePinRequest {
item_type: PinnedItemType;
item_id: string;
}
export interface ReorderPinsRequest {
items: { id: string; position: number }[];
}

View File

@@ -12,6 +12,8 @@ import {
Link2,
MoreHorizontal,
PanelRight,
Pin,
PinOff,
Plus,
Trash2,
UserMinus,
@@ -77,6 +79,8 @@ import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { timeAgo } from "@multica/core/utils";
import { cn } from "@multica/ui/lib/utils";
import { pinListOptions } from "@multica/core/pins";
import { useCreatePin, useDeletePin } from "@multica/core/pins";
import { ProgressRing } from "./progress-ring";
@@ -245,6 +249,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
// Token usage
const { data: usage } = useQuery(issueUsageOptions(id));
// Pinned state
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
const createPin = useCreatePin();
const deletePin = useDeletePin();
// Sub-issue queries
const parentIssueId = issue?.parent_issue_id;
const { data: parentIssue = null } = useQuery({
@@ -462,6 +472,27 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Tooltip>
</div>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className={cn("text-muted-foreground", isPinned && "text-foreground")}
onClick={() => {
if (isPinned) {
deletePin.mutate({ itemType: "issue", itemId: issue.id });
} else {
createPin.mutate({ item_type: "issue", item_id: issue.id });
}
}}
>
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</Button>
}
/>
<TooltipContent side="bottom">{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -596,6 +627,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
Create sub-issue
</DropdownMenuItem>
{/* Pin / Unpin */}
<DropdownMenuItem onClick={() => {
if (isPinned) {
deletePin.mutate({ itemType: "issue", itemId: issue.id });
} else {
createPin.mutate({ item_type: "issue", item_id: issue.id });
}
}}>
{isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
</DropdownMenuItem>
{/* Copy link */}
<DropdownMenuItem onClick={() => {
const url = router.getShareableUrl

View File

@@ -1,8 +1,18 @@
"use client";
import React, { useEffect } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { cn } from "@multica/ui/lib/utils";
import { AppLink, useNavigation } from "../navigation";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Inbox,
ListTodo,
@@ -18,6 +28,7 @@ import {
CircleUser,
FolderKanban,
Ellipsis,
PinOff,
} from "lucide-react";
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
@@ -52,6 +63,9 @@ import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
import { pinKeys } from "@multica/core/pins/queries";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
const personalNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@@ -76,6 +90,60 @@ function DraftDot() {
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
const wasDragged = useRef(false);
const { push } = useNavigation();
useEffect(() => {
if (isDragging) wasDragged.current = true;
}, [isDragging]);
const style = { transform: CSS.Transform.toString(transform), transition };
const href = pin.item_type === "issue" ? `/issues/${pin.item_id}` : `/projects/${pin.item_id}`;
const isActive = pathname === href;
const label = pin.item_type === "issue" && pin.identifier ? `${pin.identifier} ${pin.title}` : pin.title;
return (
<SidebarMenuItem
ref={setNodeRef}
style={style}
className={cn("group/pin", isDragging && "opacity-30")}
{...attributes}
{...listeners}
>
<SidebarMenuButton
isActive={isActive}
onClick={() => {
if (wasDragged.current) {
wasDragged.current = false;
return;
}
push(href);
}}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
{pin.item_type === "issue" ? (
<ListTodo className="size-4 shrink-0" />
) : (
<FolderKanban className="size-4 shrink-0" />
)}
<span className="truncate">{label}</span>
<button
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</button>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
interface AppSidebarProps {
/** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */
topSlot?: React.ReactNode;
@@ -106,6 +174,26 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
queryFn: () => api.listPins(),
enabled: !!wsId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pinnedItems.findIndex((p) => p.id === active.id);
const newIndex = pinnedItems.findIndex((p) => p.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(pinnedItems, oldIndex, newIndex);
reorderPins.mutate(reordered);
},
[pinnedItems, reorderPins],
);
const queryClient = useQueryClient();
const logout = () => {
@@ -263,6 +351,28 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</SidebarGroupContent>
</SidebarGroup>
{pinnedItems.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel>Pinned</SidebarGroupLabel>
<SidebarGroupContent>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={pinnedItems.map((p) => p.id)} strategy={verticalListSortingStrategy}>
<SidebarMenu className="gap-0.5">
{pinnedItems.map((pin: PinnedItem) => (
<SortablePinItem
key={pin.id}
pin={pin}
pathname={pathname}
onUnpin={() => deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })}
/>
))}
</SidebarMenu>
</SortableContext>
</DndContext>
</SidebarGroupContent>
</SidebarGroup>
)}
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>

View File

@@ -1,13 +1,15 @@
"use client";
import { useMemo, useState, useCallback, useRef } from "react";
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Trash2, UserMinus } from "lucide-react";
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Pin, PinOff, Trash2, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
import { useCreatePin, useDeletePin } from "@multica/core/pins";
import { issueListOptions } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
@@ -184,6 +186,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const { getActorName } = useActorName();
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId);
const createPin = useCreatePin();
const deletePinMut = useDeletePin();
const descEditorRef = useRef<ContentEditorRef>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
@@ -256,6 +262,16 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
}
/>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => {
if (isPinned) {
deletePinMut.mutate({ itemType: "project", itemId: projectId });
} else {
createPin.mutate({ item_type: "project", item_id: projectId });
}
}}>
{isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success("Link copied");
@@ -275,6 +291,21 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
</DropdownMenu>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-xs"
className={cn("text-muted-foreground", isPinned && "text-foreground")}
title={isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
onClick={() => {
if (isPinned) {
deletePinMut.mutate({ itemType: "project", itemId: projectId });
} else {
createPin.mutate({ item_type: "project", item_id: projectId });
}
}}
>
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon-xs"

View File

@@ -205,6 +205,14 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
})
})
// Pins
r.Route("/api/pins", func(r chi.Router) {
r.Get("/", h.ListPins)
r.Post("/", h.CreatePin)
r.Put("/reorder", h.ReorderPins)
r.Delete("/{itemType}/{itemId}", h.DeletePin)
})
// Attachments
r.Get("/api/attachments/{id}", h.GetAttachmentByID)
r.Delete("/api/attachments/{id}", h.DeleteAttachment)

View File

@@ -0,0 +1,230 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type PinnedItemResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
ItemType string `json:"item_type"`
ItemID string `json:"item_id"`
Position float64 `json:"position"`
CreatedAt string `json:"created_at"`
// Enriched fields (set by list endpoint)
Title string `json:"title"`
Identifier *string `json:"identifier,omitempty"`
Icon *string `json:"icon,omitempty"`
Status string `json:"status,omitempty"`
}
func pinnedItemToResponse(p db.PinnedItem) PinnedItemResponse {
return PinnedItemResponse{
ID: uuidToString(p.ID),
WorkspaceID: uuidToString(p.WorkspaceID),
UserID: uuidToString(p.UserID),
ItemType: p.ItemType,
ItemID: uuidToString(p.ItemID),
Position: p.Position,
CreatedAt: timestampToString(p.CreatedAt),
}
}
type CreatePinRequest struct {
ItemType string `json:"item_type"`
ItemID string `json:"item_id"`
}
type ReorderPinsRequest struct {
Items []ReorderItem `json:"items"`
}
type ReorderItem struct {
ID string `json:"id"`
Position float64 `json:"position"`
}
func (h *Handler) ListPins(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := resolveWorkspaceID(r)
pins, err := h.Queries.ListPinnedItems(r.Context(), db.ListPinnedItemsParams{
WorkspaceID: parseUUID(workspaceID),
UserID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list pins")
return
}
// Enrich with item details
resp := make([]PinnedItemResponse, 0, len(pins))
for _, p := range pins {
pr := pinnedItemToResponse(p)
switch p.ItemType {
case "issue":
issue, err := h.Queries.GetIssue(r.Context(), p.ItemID)
if err != nil {
continue // Skip deleted items
}
pr.Title = issue.Title
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
identifier := formatIdentifier(prefix, issue.Number)
pr.Identifier = &identifier
pr.Status = issue.Status
case "project":
project, err := h.Queries.GetProject(r.Context(), p.ItemID)
if err != nil {
continue // Skip deleted items
}
pr.Title = project.Title
pr.Icon = textToPtr(project.Icon)
pr.Status = project.Status
}
resp = append(resp, pr)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := resolveWorkspaceID(r)
var req CreatePinRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ItemType != "issue" && req.ItemType != "project" {
writeError(w, http.StatusBadRequest, "item_type must be 'issue' or 'project'")
return
}
if req.ItemID == "" {
writeError(w, http.StatusBadRequest, "item_id is required")
return
}
// Verify the item exists in this workspace
switch req.ItemType {
case "issue":
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID),
}); err != nil {
writeError(w, http.StatusNotFound, "issue not found")
return
}
case "project":
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID),
}); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
}
// Get max position to append at end
maxPos, err := h.Queries.GetMaxPinnedItemPosition(r.Context(), db.GetMaxPinnedItemPositionParams{
WorkspaceID: parseUUID(workspaceID),
UserID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get position")
return
}
pin, err := h.Queries.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
WorkspaceID: parseUUID(workspaceID),
UserID: parseUUID(userID),
ItemType: req.ItemType,
ItemID: parseUUID(req.ItemID),
Position: maxPos + 1,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "item already pinned")
return
}
writeError(w, http.StatusInternalServerError, "failed to create pin")
return
}
resp := pinnedItemToResponse(pin)
h.publish(protocol.EventPinCreated, workspaceID, "member", userID, map[string]any{"pin": resp})
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) DeletePin(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := resolveWorkspaceID(r)
itemType := chi.URLParam(r, "itemType")
itemID := chi.URLParam(r, "itemId")
err := h.Queries.DeletePinnedItem(r.Context(), db.DeletePinnedItemParams{
WorkspaceID: parseUUID(workspaceID),
UserID: parseUUID(userID),
ItemType: itemType,
ItemID: parseUUID(itemID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete pin")
return
}
h.publish(protocol.EventPinDeleted, workspaceID, "member", userID, map[string]any{
"item_type": itemType,
"item_id": itemID,
})
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) ReorderPins(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := resolveWorkspaceID(r)
var req ReorderPinsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
for _, item := range req.Items {
if err := h.Queries.UpdatePinnedItemPosition(r.Context(), db.UpdatePinnedItemPositionParams{
Position: item.Position,
ID: parseUUID(item.ID),
WorkspaceID: parseUUID(workspaceID),
UserID: parseUUID(userID),
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to reorder pins")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func formatIdentifier(prefix string, number int32) string {
if prefix == "" {
prefix = "ISS"
}
return prefix + "-" + strconv.Itoa(int(number))
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS pinned_item;

View File

@@ -0,0 +1,13 @@
-- Pinned items: per-user quick-access items in the sidebar
CREATE TABLE pinned_item (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
item_type TEXT NOT NULL CHECK (item_type IN ('issue', 'project')),
item_id UUID NOT NULL,
position FLOAT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, user_id, item_type, item_id)
);
CREATE INDEX idx_pinned_item_user_ws ON pinned_item (workspace_id, user_id, position);

View File

@@ -257,6 +257,16 @@ type PersonalAccessToken struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type PinnedItem struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
ItemType string `json:"item_type"`
ItemID pgtype.UUID `json:"item_id"`
Position float64 `json:"position"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`

View File

@@ -0,0 +1,163 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: pinned_item.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createPinnedItem = `-- name: CreatePinnedItem :one
INSERT INTO pinned_item (workspace_id, user_id, item_type, item_id, position)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, workspace_id, user_id, item_type, item_id, position, created_at
`
type CreatePinnedItemParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
ItemType string `json:"item_type"`
ItemID pgtype.UUID `json:"item_id"`
Position float64 `json:"position"`
}
func (q *Queries) CreatePinnedItem(ctx context.Context, arg CreatePinnedItemParams) (PinnedItem, error) {
row := q.db.QueryRow(ctx, createPinnedItem,
arg.WorkspaceID,
arg.UserID,
arg.ItemType,
arg.ItemID,
arg.Position,
)
var i PinnedItem
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.ItemType,
&i.ItemID,
&i.Position,
&i.CreatedAt,
)
return i, err
}
const deletePinnedItem = `-- name: DeletePinnedItem :exec
DELETE FROM pinned_item
WHERE workspace_id = $1 AND user_id = $2 AND item_type = $3 AND item_id = $4
`
type DeletePinnedItemParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
ItemType string `json:"item_type"`
ItemID pgtype.UUID `json:"item_id"`
}
func (q *Queries) DeletePinnedItem(ctx context.Context, arg DeletePinnedItemParams) error {
_, err := q.db.Exec(ctx, deletePinnedItem,
arg.WorkspaceID,
arg.UserID,
arg.ItemType,
arg.ItemID,
)
return err
}
const deletePinnedItemsByItem = `-- name: DeletePinnedItemsByItem :exec
DELETE FROM pinned_item
WHERE item_type = $1 AND item_id = $2
`
type DeletePinnedItemsByItemParams struct {
ItemType string `json:"item_type"`
ItemID pgtype.UUID `json:"item_id"`
}
func (q *Queries) DeletePinnedItemsByItem(ctx context.Context, arg DeletePinnedItemsByItemParams) error {
_, err := q.db.Exec(ctx, deletePinnedItemsByItem, arg.ItemType, arg.ItemID)
return err
}
const getMaxPinnedItemPosition = `-- name: GetMaxPinnedItemPosition :one
SELECT COALESCE(MAX(position), 0)::float8 AS max_position
FROM pinned_item
WHERE workspace_id = $1 AND user_id = $2
`
type GetMaxPinnedItemPositionParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) GetMaxPinnedItemPosition(ctx context.Context, arg GetMaxPinnedItemPositionParams) (float64, error) {
row := q.db.QueryRow(ctx, getMaxPinnedItemPosition, arg.WorkspaceID, arg.UserID)
var max_position float64
err := row.Scan(&max_position)
return max_position, err
}
const listPinnedItems = `-- name: ListPinnedItems :many
SELECT id, workspace_id, user_id, item_type, item_id, position, created_at FROM pinned_item
WHERE workspace_id = $1 AND user_id = $2
ORDER BY position ASC, created_at ASC
`
type ListPinnedItemsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) ListPinnedItems(ctx context.Context, arg ListPinnedItemsParams) ([]PinnedItem, error) {
rows, err := q.db.Query(ctx, listPinnedItems, arg.WorkspaceID, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PinnedItem{}
for rows.Next() {
var i PinnedItem
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.ItemType,
&i.ItemID,
&i.Position,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updatePinnedItemPosition = `-- name: UpdatePinnedItemPosition :exec
UPDATE pinned_item SET position = $1
WHERE id = $2 AND workspace_id = $3 AND user_id = $4
`
type UpdatePinnedItemPositionParams struct {
Position float64 `json:"position"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) UpdatePinnedItemPosition(ctx context.Context, arg UpdatePinnedItemPositionParams) error {
_, err := q.db.Exec(ctx, updatePinnedItemPosition,
arg.Position,
arg.ID,
arg.WorkspaceID,
arg.UserID,
)
return err
}

View File

@@ -0,0 +1,26 @@
-- name: ListPinnedItems :many
SELECT * FROM pinned_item
WHERE workspace_id = $1 AND user_id = $2
ORDER BY position ASC, created_at ASC;
-- name: CreatePinnedItem :one
INSERT INTO pinned_item (workspace_id, user_id, item_type, item_id, position)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: DeletePinnedItem :exec
DELETE FROM pinned_item
WHERE workspace_id = $1 AND user_id = $2 AND item_type = $3 AND item_id = $4;
-- name: UpdatePinnedItemPosition :exec
UPDATE pinned_item SET position = $1
WHERE id = $2 AND workspace_id = $3 AND user_id = $4;
-- name: GetMaxPinnedItemPosition :one
SELECT COALESCE(MAX(position), 0)::float8 AS max_position
FROM pinned_item
WHERE workspace_id = $1 AND user_id = $2;
-- name: DeletePinnedItemsByItem :exec
DELETE FROM pinned_item
WHERE item_type = $1 AND item_id = $2;

View File

@@ -67,6 +67,10 @@ const (
EventProjectUpdated = "project:updated"
EventProjectDeleted = "project:deleted"
// Pin events
EventPinCreated = "pin:created"
EventPinDeleted = "pin:deleted"
// Daemon events
EventDaemonHeartbeat = "daemon:heartbeat"
EventDaemonRegister = "daemon:register"