diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index 954a665c5..d0f1ba969 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -141,6 +141,12 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR slog.Warn("Failed to dispatch memo created webhook", slog.Any("err", err)) } + // Broadcast live refresh event. + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventMemoCreated, + Name: memoMessage.Name, + }) + return memoMessage, nil } @@ -471,6 +477,12 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR slog.Warn("Failed to dispatch memo updated webhook", slog.Any("err", err)) } + // Broadcast live refresh event. + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventMemoUpdated, + Name: memoMessage.Name, + }) + return memoMessage, nil } @@ -539,6 +551,12 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR return nil, status.Errorf(codes.Internal, "failed to delete memo") } + // Broadcast live refresh event. + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventMemoDeleted, + Name: request.Name, + }) + return &emptypb.Empty{}, nil } diff --git a/server/router/api/v1/reaction_service.go b/server/router/api/v1/reaction_service.go index a7c7cc3bd..a4c521fe8 100644 --- a/server/router/api/v1/reaction_service.go +++ b/server/router/api/v1/reaction_service.go @@ -97,6 +97,12 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups reactionMessage := convertReactionFromStore(reaction) + // Broadcast live refresh event (reaction belongs to a memo). + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventReactionUpserted, + Name: request.Reaction.ContentId, + }) + return reactionMessage, nil } @@ -136,6 +142,12 @@ func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.Del return nil, status.Errorf(codes.Internal, "failed to delete reaction") } + // Broadcast live refresh event (reaction belongs to a memo). + s.SSEHub.Broadcast(&SSEEvent{ + Type: SSEEventReactionDeleted, + Name: reaction.ContentID, + }) + return &emptypb.Empty{}, nil } diff --git a/server/router/api/v1/sse_handler.go b/server/router/api/v1/sse_handler.go new file mode 100644 index 000000000..af9e2389f --- /dev/null +++ b/server/router/api/v1/sse_handler.go @@ -0,0 +1,101 @@ +package v1 + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/labstack/echo/v4" + + "github.com/usememos/memos/server/auth" + "github.com/usememos/memos/store" +) + +const ( + // sseHeartbeatInterval is the interval between heartbeat pings to keep the connection alive. + sseHeartbeatInterval = 30 * time.Second +) + +// RegisterSSERoutes registers the SSE endpoint on the given Echo instance. +func RegisterSSERoutes(echoServer *echo.Echo, hub *SSEHub, storeInstance *store.Store, secret string) { + authenticator := auth.NewAuthenticator(storeInstance, secret) + echoServer.GET("/api/v1/sse", func(c echo.Context) error { + return handleSSE(c, hub, authenticator) + }) +} + +// handleSSE handles the SSE connection for live memo refresh. +// Authentication is done via Bearer token in the Authorization header, +// or via the "token" query parameter (for EventSource which cannot set headers). +func handleSSE(c echo.Context, hub *SSEHub, authenticator *auth.Authenticator) error { + // Authenticate the request. + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + // Fall back to query parameter for native EventSource support. + if token := c.QueryParam("token"); token != "" { + authHeader = "Bearer " + token + } + } + + result := authenticator.Authenticate(c.Request().Context(), authHeader) + if result == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + } + + // Set SSE headers. + w := c.Response() + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering + w.WriteHeader(http.StatusOK) + + // Flush headers immediately. + if f, ok := w.Writer.(http.Flusher); ok { + f.Flush() + } + + // Subscribe to the hub. + client := hub.Subscribe() + defer hub.Unsubscribe(client) + + // Create a ticker for heartbeat pings. + heartbeat := time.NewTicker(sseHeartbeatInterval) + defer heartbeat.Stop() + + ctx := c.Request().Context() + + slog.Debug("SSE client connected") + + for { + select { + case <-ctx.Done(): + // Client disconnected. + slog.Debug("SSE client disconnected") + return nil + + case data, ok := <-client.events: + if !ok { + // Channel closed, client was unsubscribed. + return nil + } + // Write SSE event. + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return nil + } + if f, ok := w.Writer.(http.Flusher); ok { + f.Flush() + } + + case <-heartbeat.C: + // Send a heartbeat comment to keep the connection alive. + if _, err := fmt.Fprint(w, ": heartbeat\n\n"); err != nil { + return nil + } + if f, ok := w.Writer.(http.Flusher); ok { + f.Flush() + } + } + } +} diff --git a/server/router/api/v1/sse_hub.go b/server/router/api/v1/sse_hub.go new file mode 100644 index 000000000..c75c3cc2c --- /dev/null +++ b/server/router/api/v1/sse_hub.go @@ -0,0 +1,89 @@ +package v1 + +import ( + "encoding/json" + "sync" +) + +// SSEEventType represents the type of change event. +type SSEEventType string + +const ( + SSEEventMemoCreated SSEEventType = "memo.created" + SSEEventMemoUpdated SSEEventType = "memo.updated" + SSEEventMemoDeleted SSEEventType = "memo.deleted" + SSEEventReactionUpserted SSEEventType = "reaction.upserted" + SSEEventReactionDeleted SSEEventType = "reaction.deleted" +) + +// SSEEvent represents a change event sent to SSE clients. +type SSEEvent struct { + Type SSEEventType `json:"type"` + // Name is the affected resource name (e.g., "memos/xxxx"). + // For reaction events, this is the memo resource name that the reaction belongs to. + Name string `json:"name"` +} + +// JSON returns the JSON representation of the event. +func (e *SSEEvent) JSON() []byte { + data, _ := json.Marshal(e) + return data +} + +// sseClient represents a single SSE connection. +type sseClient struct { + events chan []byte +} + +// SSEHub manages SSE client connections and broadcasts events. +// It is safe for concurrent use. +type SSEHub struct { + mu sync.RWMutex + clients map[*sseClient]struct{} +} + +// NewSSEHub creates a new SSE hub. +func NewSSEHub() *SSEHub { + return &SSEHub{ + clients: make(map[*sseClient]struct{}), + } +} + +// Subscribe registers a new client and returns it. +// The caller must call Unsubscribe when done. +func (h *SSEHub) Subscribe() *sseClient { + c := &sseClient{ + // Buffer a few events so a slow client doesn't block broadcasting. + events: make(chan []byte, 32), + } + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() + return c +} + +// Unsubscribe removes a client and closes its channel. +func (h *SSEHub) Unsubscribe(c *sseClient) { + h.mu.Lock() + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.events) + } + h.mu.Unlock() +} + +// Broadcast sends an event to all connected clients. +// Slow clients that have a full buffer will have the event dropped +// to avoid blocking the broadcaster. +func (h *SSEHub) Broadcast(event *SSEEvent) { + data := event.JSON() + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + select { + case c.events <- data: + default: + // Drop event for slow client to avoid blocking. + } + } +} diff --git a/server/router/api/v1/test/test_helper.go b/server/router/api/v1/test/test_helper.go index 779ad2eea..c3afdb38b 100644 --- a/server/router/api/v1/test/test_helper.go +++ b/server/router/api/v1/test/test_helper.go @@ -46,6 +46,7 @@ func NewTestService(t *testing.T) *TestService { Profile: testProfile, Store: testStore, MarkdownService: markdownService, + SSEHub: apiv1.NewSSEHub(), } return &TestService{ diff --git a/server/router/api/v1/v1.go b/server/router/api/v1/v1.go index 694b5bbc3..b9cb6fb40 100644 --- a/server/router/api/v1/v1.go +++ b/server/router/api/v1/v1.go @@ -31,6 +31,7 @@ type APIV1Service struct { Profile *profile.Profile Store *store.Store MarkdownService markdown.Service + SSEHub *SSEHub // thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion thumbnailSemaphore *semaphore.Weighted @@ -45,6 +46,7 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store Profile: profile, Store: store, MarkdownService: markdownService, + SSEHub: NewSSEHub(), thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations } } diff --git a/server/server.go b/server/server.go index af09c4bcd..6ddbdd6fc 100644 --- a/server/server.go +++ b/server/server.go @@ -76,6 +76,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store // Create and register RSS routes (needs markdown service from apiV1Service). rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup) + + // Register SSE endpoint for live memo refresh. + apiv1.RegisterSSERoutes(echoServer, apiV1Service.SSEHub, s.Store, s.Secret) + // Register gRPC gateway as api v1. if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil { return nil, errors.Wrap(err, "failed to register gRPC gateway") diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 45d0cee93..4c2bbb6eb 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,20 +1,157 @@ +import { PencilIcon, TrashIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import TableEditorDialog from "@/components/TableEditorDialog"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useUpdateMemo } from "@/hooks/useMemoQueries"; import { cn } from "@/lib/utils"; +import type { TableData } from "@/utils/markdown-table"; +import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table"; +import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext"; import type { ReactMarkdownProps } from "./markdown/types"; +// --------------------------------------------------------------------------- +// Table (root wrapper with edit + delete buttons) +// --------------------------------------------------------------------------- + interface TableProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } export const Table = ({ children, className, node: _node, ...props }: TableProps) => { + const tableRef = useRef(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [tableData, setTableData] = useState(null); + const [tableIndex, setTableIndex] = useState(-1); + + const { memo } = useMemoViewContext(); + const { readonly } = useMemoViewDerived(); + const { mutate: updateMemo } = useUpdateMemo(); + + /** Resolve which markdown table index this rendered table corresponds to. */ + const resolveTableIndex = useCallback(() => { + const container = tableRef.current?.closest('[class*="wrap-break-word"]'); + if (!container) return -1; + + const allTables = container.querySelectorAll("table"); + for (let i = 0; i < allTables.length; i++) { + if (tableRef.current?.contains(allTables[i])) return i; + } + return -1; + }, []); + + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const idx = resolveTableIndex(); + const tables = findAllTables(memo.content); + if (idx < 0 || idx >= tables.length) return; + + const parsed = parseMarkdownTable(tables[idx].text); + if (!parsed) return; + + setTableData(parsed); + setTableIndex(idx); + setDialogOpen(true); + }, + [memo.content, resolveTableIndex], + ); + + const handleDeleteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const idx = resolveTableIndex(); + if (idx < 0) return; + + setTableIndex(idx); + setDeleteDialogOpen(true); + }, + [resolveTableIndex], + ); + + const handleConfirmEdit = useCallback( + (markdown: string) => { + if (tableIndex < 0) return; + const newContent = replaceNthTable(memo.content, tableIndex, markdown); + updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + }, + [memo.content, memo.name, tableIndex, updateMemo], + ); + + const handleConfirmDelete = useCallback(() => { + if (tableIndex < 0) return; + // Replace the table with an empty string to delete it. + const newContent = replaceNthTable(memo.content, tableIndex, ""); + updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + setDeleteDialogOpen(false); + }, [memo.content, memo.name, tableIndex, updateMemo]); + return ( -
- - {children} -
-
+ <> +
+ + {children} +
+ {!readonly && ( +
+ + +
+ )} +
+ + + + {/* Delete confirmation dialog */} + + + + Delete table + Are you sure you want to delete this table? This action cannot be undone. + + + + + + + + + + ); }; +// --------------------------------------------------------------------------- +// Sub-components (unchanged) +// --------------------------------------------------------------------------- + interface TableHeadProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } diff --git a/web/src/components/MemoEditor/Editor/SlashCommands.tsx b/web/src/components/MemoEditor/Editor/SlashCommands.tsx index d1ab6c12d..023ad3670 100644 --- a/web/src/components/MemoEditor/Editor/SlashCommands.tsx +++ b/web/src/components/MemoEditor/Editor/SlashCommands.tsx @@ -5,10 +5,18 @@ import { useSuggestions } from "./useSuggestions"; const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => { const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => { - // Remove trigger char + word, then insert command output + // Remove trigger char + word. actions.removeText(index, word.length); + + // If the command has a dialog action, invoke it instead of inserting text. + if (cmd.action) { + cmd.action(); + return; + } + + // Otherwise insert the command output text. actions.insertText(cmd.run()); - // Position cursor relative to insertion point, if specified + // Position cursor relative to insertion point, if specified. if (cmd.cursorOffset) { actions.setCursorPosition(index + cmd.cursorOffset); } diff --git a/web/src/components/MemoEditor/Editor/commands.ts b/web/src/components/MemoEditor/Editor/commands.ts index 4aa58b44a..9f8b11254 100644 --- a/web/src/components/MemoEditor/Editor/commands.ts +++ b/web/src/components/MemoEditor/Editor/commands.ts @@ -1,7 +1,10 @@ export interface Command { name: string; + /** Returns text to insert. Ignored if `action` is set. */ run: () => string; cursorOffset?: number; + /** If set, called instead of inserting run() text. Used for dialog-based commands. */ + action?: () => void; } export const editorCommands: Command[] = [ @@ -26,3 +29,18 @@ export const editorCommands: Command[] = [ cursorOffset: 1, }, ]; + +/** + * Create the full editor commands list, with the table command + * wired to open the table editor dialog instead of inserting raw markdown. + */ +export function createEditorCommands(onOpenTableEditor?: () => void): Command[] { + if (!onOpenTableEditor) return editorCommands; + + return editorCommands.map((cmd) => { + if (cmd.name === "table") { + return { ...cmd, action: onOpenTableEditor }; + } + return cmd; + }); +} diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 0ef458f9e..4b7c4ed62 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -35,6 +35,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward isInIME = false, onCompositionStart, onCompositionEnd, + commands: customCommands, } = props; const editorRef = useRef(null); @@ -205,7 +206,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward onCompositionEnd={onCompositionEnd} > - + ); }); diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index ec962b5e7..6a3bd3b62 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -1,9 +1,20 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; -import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; +import { + FileIcon, + LinkIcon, + LoaderIcon, + type LucideIcon, + MapPinIcon, + Maximize2Icon, + MoreHorizontalIcon, + PlusIcon, + TableIcon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useDebounce } from "react-use"; import { useReverseGeocoding } from "@/components/map"; +import TableEditorDialog from "@/components/TableEditorDialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -30,6 +41,7 @@ const InsertMenu = (props: InsertMenuProps) => { const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [locationDialogOpen, setLocationDialogOpen] = useState(false); + const [tableDialogOpen, setTableDialogOpen] = useState(false); const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false); const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay( @@ -77,6 +89,17 @@ const InsertMenu = (props: InsertMenuProps) => { setLinkDialogOpen(true); }, []); + const handleOpenTableDialog = useCallback(() => { + setTableDialogOpen(true); + }, []); + + const handleTableConfirm = useCallback( + (markdown: string) => { + props.onInsertText?.(markdown); + }, + [props], + ); + const handleLocationClick = useCallback(() => { setLocationDialogOpen(true); if (!initialLocation && !location.locationInitialized) { @@ -127,6 +150,12 @@ const InsertMenu = (props: InsertMenuProps) => { icon: FileIcon, onClick: handleUploadClick, }, + { + key: "table", + label: "Table", + icon: TableIcon, + onClick: handleOpenTableDialog, + }, { key: "link", label: t("tooltip.link-memo"), @@ -140,7 +169,7 @@ const InsertMenu = (props: InsertMenuProps) => { onClick: handleLocationClick, }, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, - [handleLocationClick, handleOpenLinkDialog, handleUploadClick, t], + [handleLocationClick, handleOpenLinkDialog, handleOpenTableDialog, handleUploadClick, t], ); return ( @@ -207,6 +236,8 @@ const InsertMenu = (props: InsertMenuProps) => { onCancel={handleLocationCancel} onConfirm={handleLocationConfirm} /> + + ); }; diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 5cc14f784..41e8f2b39 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -1,11 +1,12 @@ -import { forwardRef } from "react"; +import { forwardRef, useMemo } from "react"; import Editor, { type EditorRefActions } from "../Editor"; +import { createEditorCommands } from "../Editor/commands"; import { useBlobUrls, useDragAndDrop } from "../hooks"; import { useEditorContext } from "../state"; import type { EditorContentProps } from "../types"; import type { LocalFile } from "../types/attachment"; -export const EditorContent = forwardRef(({ placeholder }, ref) => { +export const EditorContent = forwardRef(({ placeholder, onOpenTableEditor }, ref) => { const { state, actions, dispatch } = useEditorContext(); const { createBlobUrl } = useBlobUrls(); @@ -54,6 +55,9 @@ export const EditorContent = forwardRef(({ event.preventDefault(); }; + // Build commands with the table editor action wired in. + const commands = useMemo(() => createEditorCommands(onOpenTableEditor), [onOpenTableEditor]); + return (
(({ onPaste={handlePaste} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + commands={commands} />
); diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index ba03378ad..9c8781b2f 100644 --- a/web/src/components/MemoEditor/components/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; import VisibilitySelector from "../Toolbar/VisibilitySelector"; import type { EditorToolbarProps } from "../types"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onInsertText }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -34,6 +34,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa location={state.metadata.location} onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} + onInsertText={onInsertText} memoName={memoName} /> diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index ec05a99d3..c0069b485 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "react-hot-toast"; +import TableEditorDialog from "@/components/TableEditorDialog"; import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; @@ -68,6 +69,17 @@ const MemoEditorImpl: React.FC = ({ dispatch(actions.toggleFocusMode()); }; + // Table editor dialog (shared by slash command and toolbar). + const [tableDialogOpen, setTableDialogOpen] = useState(false); + + const handleOpenTableEditor = useCallback(() => { + setTableDialogOpen(true); + }, []); + + const handleTableConfirm = useCallback((markdown: string) => { + editorRef.current?.insertText(markdown); + }, []); + useKeyboard(editorRef, { onSave: handleSave }); async function handleSave() { @@ -142,14 +154,21 @@ const MemoEditorImpl: React.FC = ({ {/* Editor content grows to fill available space in focus mode */} - + {/* Metadata and toolbar grouped together at bottom */}
- + editorRef.current?.insertText(text)} + />
+ + ); }; diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 13f91ff48..13bcf5917 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -18,12 +18,14 @@ export interface MemoEditorProps { export interface EditorContentProps { placeholder?: string; autoFocus?: boolean; + onOpenTableEditor?: () => void; } export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; + onInsertText?: (text: string) => void; } export interface EditorMetadataProps { @@ -68,6 +70,7 @@ export interface InsertMenuProps { location?: Location; onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; + onInsertText?: (text: string) => void; memoName?: string; } @@ -92,6 +95,8 @@ export interface EditorProps { isInIME?: boolean; onCompositionStart?: () => void; onCompositionEnd?: () => void; + /** Custom commands for slash menu. If not provided, defaults are used. */ + commands?: import("../Editor/commands").Command[]; } export interface VisibilitySelectorProps { diff --git a/web/src/components/Navigation.tsx b/web/src/components/Navigation.tsx index 484f0837b..faf07c55b 100644 --- a/web/src/components/Navigation.tsx +++ b/web/src/components/Navigation.tsx @@ -8,6 +8,7 @@ import { Routes } from "@/router"; import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemosLogo from "./MemosLogo"; +import SSEStatusIndicator from "./SSEStatusIndicator"; import UserMenu from "./UserMenu"; interface NavLinkItem { @@ -114,7 +115,10 @@ const Navigation = (props: Props) => { ))} {currentUser && ( -
+
+
+ +
)} diff --git a/web/src/components/SSEStatusIndicator.tsx b/web/src/components/SSEStatusIndicator.tsx new file mode 100644 index 000000000..ca4df407d --- /dev/null +++ b/web/src/components/SSEStatusIndicator.tsx @@ -0,0 +1,36 @@ +import { useSSEConnectionStatus } from "@/hooks/useLiveMemoRefresh"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +/** + * A small colored dot that indicates the SSE live-refresh connection status. + * - Green = connected (live updates active) + * - Yellow/pulsing = connecting + * - Red = disconnected (updates not live) + */ +const SSEStatusIndicator = () => { + const status = useSSEConnectionStatus(); + + const label = + status === "connected" ? "Live updates active" : status === "connecting" ? "Connecting to live updates..." : "Live updates unavailable"; + + return ( + + + + + + + {label} + + ); +}; + +export default SSEStatusIndicator; diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx new file mode 100644 index 000000000..e414557c3 --- /dev/null +++ b/web/src/components/TableEditorDialog.tsx @@ -0,0 +1,415 @@ +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import type { ColumnAlignment, TableData } from "@/utils/markdown-table"; +import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table"; +import { Button } from "./ui/button"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { VisuallyHidden } from "./ui/visually-hidden"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TableEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialData?: TableData | null; + onConfirm: (markdown: string) => void; +} + +type SortState = { col: number; dir: "asc" | "desc" } | null; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: TableEditorDialogProps) => { + const [headers, setHeaders] = useState([]); + const [rows, setRows] = useState([]); + const [alignments, setAlignments] = useState([]); + const [sortState, setSortState] = useState(null); + + const inputRefs = useRef>(new Map()); + + const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => { + if (el) inputRefs.current.set(key, el); + else inputRefs.current.delete(key); + }, []); + + useEffect(() => { + if (open) { + if (initialData) { + setHeaders([...initialData.headers]); + setRows(initialData.rows.map((r) => [...r])); + setAlignments([...initialData.alignments]); + } else { + const empty = createEmptyTable(3, 2); + setHeaders(empty.headers); + setRows(empty.rows); + setAlignments(empty.alignments); + } + setSortState(null); + } + }, [open, initialData]); + + const colCount = headers.length; + const rowCount = rows.length; + + // ---- Cell editing ---- + + const updateHeader = (col: number, value: string) => { + setHeaders((prev) => { + const next = [...prev]; + next[col] = value; + return next; + }); + }; + + const updateCell = (row: number, col: number, value: string) => { + setRows((prev) => { + const next = prev.map((r) => [...r]); + next[row][col] = value; + return next; + }); + }; + + // ---- Add / Remove / Insert ---- + + const addColumn = () => { + setHeaders((prev) => [...prev, ""]); + setRows((prev) => prev.map((r) => [...r, ""])); + setAlignments((prev) => [...prev, "none"]); + setSortState(null); + }; + + const insertColumnAt = (index: number) => { + setHeaders((prev) => [...prev.slice(0, index), "", ...prev.slice(index)]); + setRows((prev) => prev.map((r) => [...r.slice(0, index), "", ...r.slice(index)])); + setAlignments((prev) => [...prev.slice(0, index), "none" as ColumnAlignment, ...prev.slice(index)]); + setSortState(null); + }; + + const removeColumn = (col: number) => { + if (colCount <= 1) return; + setHeaders((prev) => prev.filter((_, i) => i !== col)); + setRows((prev) => prev.map((r) => r.filter((_, i) => i !== col))); + setAlignments((prev) => prev.filter((_, i) => i !== col)); + setSortState(null); + }; + + const addRow = () => { + setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]); + }; + + const insertRowAt = (index: number) => { + setRows((prev) => [...prev.slice(0, index), Array.from({ length: colCount }, () => ""), ...prev.slice(index)]); + }; + + const removeRow = (row: number) => { + if (rowCount <= 1) return; + setRows((prev) => prev.filter((_, i) => i !== row)); + }; + + // ---- Sorting ---- + + const sortByColumn = (col: number) => { + let newDir: "asc" | "desc" = "asc"; + if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc"; + setSortState({ col, dir: newDir }); + setRows((prev) => { + const sorted = [...prev].sort((a, b) => { + const va = (a[col] || "").toLowerCase(); + const vb = (b[col] || "").toLowerCase(); + const na = Number(va); + const nb = Number(vb); + if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na; + const cmp = va.localeCompare(vb); + return newDir === "asc" ? cmp : -cmp; + }); + return sorted; + }); + }; + + // ---- Tab / keyboard navigation ---- + + const handleKeyDown = (e: React.KeyboardEvent, row: number, col: number) => { + if (e.key !== "Tab") return; + e.preventDefault(); + const nextCol = e.shiftKey ? col - 1 : col + 1; + let nextRow = row; + if (nextCol >= colCount) { + if (row < rowCount - 1) { + nextRow = row + 1; + focusCell(nextRow, 0); + } else { + addRow(); + setTimeout(() => focusCell(rowCount, 0), 0); + } + } else if (nextCol < 0) { + if (row > 0) { + nextRow = row - 1; + focusCell(nextRow, colCount - 1); + } else { + focusCell(-1, colCount - 1); + } + } else { + focusCell(nextRow, nextCol); + } + }; + + const focusCell = (row: number, col: number) => { + inputRefs.current.get(`${row}:${col}`)?.focus(); + }; + + const handleConfirm = () => { + const md = serializeMarkdownTable({ headers, rows, alignments }); + onConfirm(md); + onOpenChange(false); + }; + + const SortIndicator = ({ col }: { col: number }) => { + if (sortState?.col === col) { + return sortState.dir === "asc" ? : ; + } + return ; + }; + + const totalColSpan = colCount + 2; + + return ( + + + + + + + Table Editor + + + Edit table headers, rows, columns and sort data + + +
+ {/* Scrollable table area */} +
+ {/* Clip wrapper: ensures blue highlight lines don't extend beyond the table */} +
+ + {/* ============ STICKY HEADER ============ */} + + {/* Mask row: solid background that hides content scrolling behind the header */} + + + + {/* Header row */} + + {/* Row-number spacer */} + + ))} + + {/* Add column at end */} + + + + + {/* ============ DATA ROWS ============ */} + + {rows.map((row, rowIdx) => ( + + + {/* Row number โ€” with insert-row zone on top border */} + + + {/* Data cells */} + {row.map((cell, col) => ( + + ))} + + {/* Row delete button */} + + + + ))} + +
+
+ + {headers.map((header, col) => ( + + {/* ---- Insert-column zone (between col-1 and col) ---- */} + {col > 0 && ( +
insertColumnAt(col)} + > + {/* Blue vertical line through the entire table */} +
+ {/* + button โ€” absolutely centered on the column border */} + + + + + Insert column + +
+ )} + + {/* Header cell โ€” bg covers input + sort + delete */} +
+ setInputRef(`-1:${col}`, el)} + style={{ fontFamily: MONO_FONT }} + className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40" + value={header} + onChange={(e) => updateHeader(col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, -1, col)} + placeholder={`Col ${col + 1}`} + /> + + + + + Sort column + + {colCount > 1 && ( + + + + + Remove column + + )} +
+
+ + + + + Add column + +
+ {rowIdx > 0 && ( +
insertRowAt(rowIdx)} + > + {/* Blue horizontal line extending across the table */} +
+ {/* + button at intersection of row border and first-column border */} + + + + + Insert row + +
+ )} + {rowIdx + 1} +
+ setInputRef(`${rowIdx}:${col}`, el)} + style={{ fontFamily: MONO_FONT }} + className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40" + value={cell} + onChange={(e) => updateCell(rowIdx, col, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} + /> + + {rowCount > 1 && ( + + + + + Remove row + + )} +
+
+ + {/* Add row button below the table */} +
+ +
+
+ + {/* ============ FOOTER ============ */} +
+
+ + {colCount} {colCount === 1 ? "column" : "columns"} ยท {rowCount} {rowCount === 1 ? "row" : "rows"} + + + +
+
+ + +
+
+
+
+
+ ); +}; + +export default TableEditorDialog; diff --git a/web/src/hooks/useLiveMemoRefresh.ts b/web/src/hooks/useLiveMemoRefresh.ts new file mode 100644 index 000000000..3f0b98049 --- /dev/null +++ b/web/src/hooks/useLiveMemoRefresh.ts @@ -0,0 +1,203 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { getAccessToken } from "@/auth-state"; +import { memoKeys } from "@/hooks/useMemoQueries"; +import { userKeys } from "@/hooks/useUserQueries"; + +/** + * Reconnection parameters for SSE connection. + */ +const INITIAL_RETRY_DELAY_MS = 1000; +const MAX_RETRY_DELAY_MS = 30000; +const RETRY_BACKOFF_MULTIPLIER = 2; + +// --------------------------------------------------------------------------- +// Shared connection status store (singleton) +// --------------------------------------------------------------------------- + +export type SSEConnectionStatus = "connected" | "disconnected" | "connecting"; + +type Listener = () => void; + +let _status: SSEConnectionStatus = "disconnected"; +const _listeners = new Set(); + +function getSSEStatus(): SSEConnectionStatus { + return _status; +} + +function setSSEStatus(s: SSEConnectionStatus) { + if (_status !== s) { + _status = s; + _listeners.forEach((l) => l()); + } +} + +function subscribeSSEStatus(listener: Listener): () => void { + _listeners.add(listener); + return () => _listeners.delete(listener); +} + +/** + * React hook that returns the current SSE connection status. + * Re-renders the component whenever the status changes. + */ +export function useSSEConnectionStatus(): SSEConnectionStatus { + return useSyncExternalStore(subscribeSSEStatus, getSSEStatus, getSSEStatus); +} + +// --------------------------------------------------------------------------- +// Main hook +// --------------------------------------------------------------------------- + +/** + * useLiveMemoRefresh connects to the server's SSE endpoint and + * invalidates relevant React Query caches when change events + * (memos, reactions) are received. + * + * This enables real-time updates across all open instances of the app. + */ +export function useLiveMemoRefresh() { + const queryClient = useQueryClient(); + const retryDelayRef = useRef(INITIAL_RETRY_DELAY_MS); + const abortControllerRef = useRef(null); + + const handleEvent = useCallback((event: SSEChangeEvent) => handleSSEEvent(event, queryClient), [queryClient]); + + useEffect(() => { + let mounted = true; + let retryTimeout: ReturnType | null = null; + + const connect = async () => { + if (!mounted) return; + + const token = getAccessToken(); + if (!token) { + setSSEStatus("disconnected"); + // Not logged in; retry after a delay in case the user logs in. + retryTimeout = setTimeout(connect, 5000); + return; + } + + setSSEStatus("connecting"); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + const response = await fetch("/api/v1/sse", { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: abortController.signal, + credentials: "include", + }); + + if (!response.ok || !response.body) { + throw new Error(`SSE connection failed: ${response.status}`); + } + + // Successfully connected - reset retry delay. + retryDelayRef.current = INITIAL_RETRY_DELAY_MS; + setSSEStatus("connected"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (mounted) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE messages (separated by double newlines). + const messages = buffer.split("\n\n"); + // Keep the last incomplete chunk in the buffer. + buffer = messages.pop() || ""; + + for (const message of messages) { + if (!message.trim()) continue; + + // Parse SSE format: lines starting with "data: " contain JSON payload. + // Lines starting with ":" are comments (heartbeats). + for (const line of message.split("\n")) { + if (line.startsWith("data: ")) { + const jsonStr = line.slice(6); + try { + const event = JSON.parse(jsonStr) as SSEChangeEvent; + handleEvent(event); + } catch { + // Ignore malformed JSON. + } + } + } + } + } + } catch (err: unknown) { + if (err instanceof DOMException && err.name === "AbortError") { + // Intentional abort, don't reconnect. + setSSEStatus("disconnected"); + return; + } + // Connection lost or failed - reconnect with backoff. + } + + setSSEStatus("disconnected"); + + // Reconnect with exponential backoff. + if (mounted) { + const delay = retryDelayRef.current; + retryDelayRef.current = Math.min(delay * RETRY_BACKOFF_MULTIPLIER, MAX_RETRY_DELAY_MS); + retryTimeout = setTimeout(connect, delay); + } + }; + + connect(); + + return () => { + mounted = false; + setSSEStatus("disconnected"); + if (retryTimeout) { + clearTimeout(retryTimeout); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [handleEvent]); +} + +// --------------------------------------------------------------------------- +// Event handling +// --------------------------------------------------------------------------- + +interface SSEChangeEvent { + type: string; + name: string; +} + +function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType) { + switch (event.type) { + case "memo.created": + queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); + break; + + case "memo.updated": + queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) }); + queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); + break; + + case "memo.deleted": + queryClient.removeQueries({ queryKey: memoKeys.detail(event.name) }); + queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); + break; + + case "reaction.upserted": + case "reaction.deleted": + queryClient.invalidateQueries({ queryKey: memoKeys.detail(event.name) }); + queryClient.invalidateQueries({ queryKey: memoKeys.lists() }); + break; + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 5d617b210..a87b1a14b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -12,6 +12,7 @@ import { refreshAccessToken } from "@/connect"; import { AuthProvider, useAuth } from "@/contexts/AuthContext"; import { InstanceProvider, useInstance } from "@/contexts/InstanceContext"; import { ViewProvider } from "@/contexts/ViewContext"; +import { useLiveMemoRefresh } from "@/hooks/useLiveMemoRefresh"; import { useTokenRefreshOnFocus } from "@/hooks/useTokenRefreshOnFocus"; import { queryClient } from "@/lib/query-client"; import router from "./router"; @@ -46,6 +47,9 @@ function AppInitializer({ children }: { children: React.ReactNode }) { // Related: https://github.com/usememos/memos/issues/5589 useTokenRefreshOnFocus(refreshAccessToken, !!currentUser); + // Live refresh: listen for memo changes via SSE and invalidate caches. + useLiveMemoRefresh(); + if (!authInitialized || !instanceInitialized) { return null; } diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts new file mode 100644 index 000000000..1e6368176 --- /dev/null +++ b/web/src/utils/markdown-table.ts @@ -0,0 +1,203 @@ +/** + * Utilities for parsing, serializing, and manipulating markdown tables. + */ + +export interface TableData { + headers: string[]; + rows: string[][]; + /** Column alignments: "left" | "center" | "right" | "none". */ + alignments: ColumnAlignment[]; +} + +export type ColumnAlignment = "left" | "center" | "right" | "none"; + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/** + * Parse a markdown table string into structured TableData. + * + * Expects a standard GFM table: + * | Header1 | Header2 | + * | ------- | ------- | + * | cell | cell | + */ +export function parseMarkdownTable(md: string): TableData | null { + const lines = md + .trim() + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + if (lines.length < 2) return null; + + const parseRow = (line: string): string[] => { + // Strip leading/trailing pipes and split by pipe. + let trimmed = line; + if (trimmed.startsWith("|")) trimmed = trimmed.slice(1); + if (trimmed.endsWith("|")) trimmed = trimmed.slice(0, -1); + return trimmed.split("|").map((cell) => cell.trim()); + }; + + const headers = parseRow(lines[0]); + + // Parse the separator line for alignments. + const sepCells = parseRow(lines[1]); + const isSeparator = sepCells.every((cell) => /^:?-+:?$/.test(cell.trim())); + if (!isSeparator) return null; + + const alignments: ColumnAlignment[] = sepCells.map((cell) => { + const c = cell.trim(); + const left = c.startsWith(":"); + const right = c.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return "none"; + }); + + const rows: string[][] = []; + for (let i = 2; i < lines.length; i++) { + const cells = parseRow(lines[i]); + // Pad or trim to match header count. + while (cells.length < headers.length) cells.push(""); + if (cells.length > headers.length) cells.length = headers.length; + rows.push(cells); + } + + return { headers, rows, alignments }; +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/** + * Serialize TableData into a properly-aligned markdown table string. + */ +export function serializeMarkdownTable(data: TableData): string { + const { headers, rows, alignments } = data; + const colCount = headers.length; + + // Calculate maximum width per column (minimum 3 for the separator). + const widths: number[] = []; + for (let c = 0; c < colCount; c++) { + let max = Math.max(3, headers[c].length); + for (const row of rows) { + max = Math.max(max, (row[c] || "").length); + } + widths.push(max); + } + + const padCell = (text: string, width: number, align: ColumnAlignment): string => { + const t = text || ""; + const padding = width - t.length; + if (padding <= 0) return t; + if (align === "right") return " ".repeat(padding) + t; + if (align === "center") { + const left = Math.floor(padding / 2); + const right = padding - left; + return " ".repeat(left) + t + " ".repeat(right); + } + return t + " ".repeat(padding); + }; + + const formatRow = (cells: string[]): string => { + const formatted = cells.map((cell, i) => { + const align = alignments[i] || "none"; + return padCell(cell, widths[i], align); + }); + return "| " + formatted.join(" | ") + " |"; + }; + + const separator = widths.map((w, i) => { + const align = alignments[i] || "none"; + const dashes = "-".repeat(w); + if (align === "center") return ":" + dashes.slice(1, -1) + ":"; + if (align === "right") return dashes.slice(0, -1) + ":"; + if (align === "left") return ":" + dashes.slice(1); + return dashes; + }); + const separatorLine = "| " + separator.join(" | ") + " |"; + + const headerLine = formatRow(headers); + const rowLines = rows.map((row) => formatRow(row)); + + return [headerLine, separatorLine, ...rowLines].join("\n"); +} + +// --------------------------------------------------------------------------- +// Find & Replace +// --------------------------------------------------------------------------- + +/** Regex that matches a full markdown table block (one or more table lines). */ +const TABLE_LINE = /^\s*\|.+\|\s*$/; + +export interface TableMatch { + /** The raw markdown of the table. */ + text: string; + /** Start index in the source string. */ + start: number; + /** End index (exclusive) in the source string. */ + end: number; +} + +/** + * Find all markdown table blocks in a content string. + */ +export function findAllTables(content: string): TableMatch[] { + const lines = content.split("\n"); + const tables: TableMatch[] = []; + let i = 0; + let offset = 0; + + while (i < lines.length) { + if (TABLE_LINE.test(lines[i])) { + const startLine = i; + const startOffset = offset; + // Consume all consecutive table lines. + while (i < lines.length && TABLE_LINE.test(lines[i])) { + offset += lines[i].length + 1; // +1 for newline + i++; + } + const endOffset = offset - 1; // exclude trailing newline + const text = lines.slice(startLine, i).join("\n"); + // Only count if it has at least a header + separator (2 lines). + if (i - startLine >= 2) { + tables.push({ text, start: startOffset, end: endOffset }); + } + } else { + offset += lines[i].length + 1; + i++; + } + } + + return tables; +} + +/** + * Replace the nth table in the content with new markdown. + */ +export function replaceNthTable(content: string, tableIndex: number, newTableMarkdown: string): string { + const tables = findAllTables(content); + if (tableIndex < 0 || tableIndex >= tables.length) return content; + + const table = tables[tableIndex]; + return content.slice(0, table.start) + newTableMarkdown + content.slice(table.end); +} + +// --------------------------------------------------------------------------- +// Default empty table +// --------------------------------------------------------------------------- + +/** + * Create a default empty table with the given dimensions. + */ +export function createEmptyTable(cols = 2, rows = 2): TableData { + return { + headers: Array.from({ length: cols }, (_, i) => `Header ${i + 1}`), + rows: Array.from({ length: rows }, () => Array.from({ length: cols }, () => "")), + alignments: Array.from({ length: cols }, () => "none" as ColumnAlignment), + }; +} diff --git a/web/vite.config.mts b/web/vite.config.mts index 5b63cb382..1433c39f2 100644 --- a/web/vite.config.mts +++ b/web/vite.config.mts @@ -16,6 +16,12 @@ export default defineConfig({ host: "0.0.0.0", port: 3001, proxy: { + "^/api/v1/sse": { + target: devProxyServer, + xfwd: true, + // SSE requires no response buffering and longer timeout. + timeout: 0, + }, "^/api": { target: devProxyServer, xfwd: true,