From 520dff6bfc4aad5b7ef667009518389bbb8df608 Mon Sep 17 00:00:00 2001 From: Riley Crane Date: Fri, 21 Aug 2026 17:03:28 -0700 Subject: [PATCH] fix(desktop): address markdown-viewer review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract pane-open handlers (manage channel + markdown doc) into useChannelPaneOpeners, bringing ChannelScreen back under the 1000-line file-size ratchet (1016 -> 984). - Include the markdown doc panel in hasAuxiliaryPanel so narrow layouts enter single-panel view (and Escape-to-close engages) the same way sibling panes do, instead of squeezing the timeline. - Key MarkdownDocPanel by URL so opening a different document resets the Preview/Code toggle. - Pre-gate the open-in-viewer affordance on the advertised imeta size so a >2MiB .md is not fetched twice just to be refused (decoder still re-checks — imeta size is untrusted). - Document the 150-line syntax-highlight cap inherited from SyntaxHighlightedCode in the Code view. Signed-off-by: Riley Crane Co-authored-by: Fizz Co-Authored-By: Claude Fable 5 --- .../src/features/channels/ui/ChannelPane.tsx | 3 + .../features/channels/ui/ChannelScreen.tsx | 60 +++--------- .../features/channels/ui/MarkdownDocPanel.tsx | 2 + .../channels/ui/useChannelPaneOpeners.ts | 97 +++++++++++++++++++ desktop/src/shared/ui/markdown/FileCard.tsx | 8 +- 5 files changed, 121 insertions(+), 49 deletions(-) create mode 100644 desktop/src/features/channels/ui/useChannelPaneOpeners.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 87242f8b2c..56d9475464 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1015,7 +1015,10 @@ export const ChannelPane = React.memo(function ChannelPane({ // reappears when it closes. Opening a document clears competitors // in the screen-level handler, so it is never dead on arrival. const panel = ( + // Keyed by URL so opening a different document resets the + // Preview/Code toggle instead of inheriting the previous doc's. { - if (!requireThreadEditResolution()) return; - if (activeChannel?.channelType === "forum") { - openGlobalChannelManagement(); - return; - } - if (channelManagementOpen) { - setChannelManagementOpen(false); - return; - } - setOpenThreadHeadId(null); - setExpandedThreadReplyIds(new Set()); - setThreadScrollTargetId(null); - setThreadReplyTargetId(null); - handleCloseAgentSession(); - setProfilePanelPubkey(null); - setChannelManagementOpen(true); - }, [ - activeChannel?.channelType, + const { handleManageChannel, handleOpenMarkdownDoc } = useChannelPaneOpeners({ + channelType: activeChannel?.channelType, channelManagementOpen, + closeAgentSession: handleCloseAgentSession, openGlobalChannelManagement, + openMarkdownDoc, requireThreadEditResolution, setChannelManagementOpen, + setExpandedThreadReplyIds, setOpenThreadHeadId, - handleCloseAgentSession, setProfilePanelPubkey, - ]); + setThreadReplyTargetId, + setThreadScrollTargetId, + }); const handleToggleMembers = React.useCallback( () => setIsMembersSidebarOpen((prev) => !prev), [], ); - // Opening a document clears competing panes (mirrors handleManageChannel / - // useChannelProfilePanel) so the doc panel — lowest in ChannelPane's pane - // priority chain — is never opened dead behind another pane. - const handleOpenMarkdownDoc = React.useCallback( - (doc: MarkdownDocTarget) => { - setOpenThreadHeadId(null); - setExpandedThreadReplyIds(new Set()); - setThreadScrollTargetId(null); - setThreadReplyTargetId(null); - handleCloseAgentSession(); - setChannelManagementOpen(false); - setProfilePanelPubkey(null); - openMarkdownDoc(doc.url, doc.filename); - }, - [ - handleCloseAgentSession, - openMarkdownDoc, - setChannelManagementOpen, - setOpenThreadHeadId, - setProfilePanelPubkey, - ], - ); const channelHeader = React.useMemo( () => ( + {/* Shiki's synchronous-tokenization guard caps highlighting at + 150 lines; longer documents render as plain text here. */} ) diff --git a/desktop/src/features/channels/ui/useChannelPaneOpeners.ts b/desktop/src/features/channels/ui/useChannelPaneOpeners.ts new file mode 100644 index 0000000000..98cd0fb066 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelPaneOpeners.ts @@ -0,0 +1,97 @@ +import * as React from "react"; + +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type UseChannelPaneOpenersOptions = { + channelType: string | undefined; + channelManagementOpen: boolean; + closeAgentSession: () => void; + openGlobalChannelManagement: () => void; + openMarkdownDoc: (url: string, filename: string) => void; + /** Prompts for an unresolved in-progress thread edit; false blocks the open. */ + requireThreadEditResolution: () => boolean; + setChannelManagementOpen: (open: boolean) => void; + setExpandedThreadReplyIds: React.Dispatch>>; + setOpenThreadHeadId: (id: string | null) => void; + setProfilePanelPubkey: (pubkey: string | null) => void; + setThreadReplyTargetId: (id: string | null) => void; + setThreadScrollTargetId: (id: string | null) => void; +}; + +/** + * Open handlers for ChannelPane's mutually exclusive auxiliary panes. + * + * Each opener clears every competing pane before opening its own (mirroring + * useChannelProfilePanel) so a newly opened pane is never dead behind a + * higher-priority sibling in ChannelPane's pane priority chain. + */ +export function useChannelPaneOpeners({ + channelType, + channelManagementOpen, + closeAgentSession, + openGlobalChannelManagement, + openMarkdownDoc, + requireThreadEditResolution, + setChannelManagementOpen, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, +}: UseChannelPaneOpenersOptions) { + const clearCompetingPanes = React.useCallback(() => { + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + closeAgentSession(); + setProfilePanelPubkey(null); + }, [ + closeAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + ]); + + const handleManageChannel = React.useCallback(() => { + if (!requireThreadEditResolution()) return; + if (channelType === "forum") { + openGlobalChannelManagement(); + return; + } + if (channelManagementOpen) { + setChannelManagementOpen(false); + return; + } + clearCompetingPanes(); + setChannelManagementOpen(true); + }, [ + channelType, + channelManagementOpen, + clearCompetingPanes, + openGlobalChannelManagement, + requireThreadEditResolution, + setChannelManagementOpen, + ]); + + const handleOpenMarkdownDoc = React.useCallback( + (doc: MarkdownDocTarget) => { + // Opening a doc closes the thread pane, so an in-progress thread edit + // must be resolved first — same contract as the sibling pane openers. + if (!requireThreadEditResolution()) return; + clearCompetingPanes(); + setChannelManagementOpen(false); + openMarkdownDoc(doc.url, doc.filename); + }, + [ + clearCompetingPanes, + openMarkdownDoc, + requireThreadEditResolution, + setChannelManagementOpen, + ], + ); + + return { handleManageChannel, handleOpenMarkdownDoc }; +} diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 38ee068be9..637bf5b3d6 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -6,7 +6,10 @@ import { invokeTauri } from "@/shared/api/tauri"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; import { isRelayDownloadable } from "./mediaEntry"; -import { isMarkdownDocFilename } from "./markdownDocFile"; +import { + isMarkdownDocFilename, + MAX_MARKDOWN_DOC_BYTES, +} from "./markdownDocFile"; import { useMarkdownDocViewer } from "./markdownDocViewerContext"; import { useMarkdownRuntime } from "./runtimeContext"; @@ -56,6 +59,9 @@ export function FileCard({ const opensInViewer = openMarkdownDoc !== null && isMarkdownDocFilename(filename) && + // Advertised-size pre-gate: skip fetching a doc the viewer would refuse + // anyway. The imeta size is untrusted, so the decoder re-checks post-fetch. + (size == null || size <= MAX_MARKDOWN_DOC_BYTES) && isRelayDownloadable(href, relayOrigin ?? undefined); return (