Skip to content

Commit 61bde41

Browse files
Add devtools sidebar toggle
Show a dev-only devtools panel button beside the new note action and toggle the native panel show/hide command from the sidebar chrome.
1 parent 01dfd25 commit 61bde41

6 files changed

Lines changed: 167 additions & 51 deletions

File tree

apps/desktop/src/devtools-panel/host.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type DevtoolsPanelAction =
5757
| "countdown:note-300"
5858
| "countdown:zoom-60"
5959
| "countdown:zoom-300"
60+
| "panel:closed"
6061
| "error:trigger";
6162

6263
export function DevtoolsFloatingPanelHost() {
@@ -427,6 +428,8 @@ function useDevtoolsPanelActions() {
427428
case "countdown:zoom-300":
428429
createWithCountdown(300, "https://zoom.us/j/1234567890");
429430
return;
431+
case "panel:closed":
432+
return;
430433
case "error:trigger":
431434
setShouldThrow(true);
432435
return;

apps/desktop/src/main/body.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
fireEvent,
55
render,
66
screen,
7+
waitFor,
78
} from "@testing-library/react";
89
import { beforeEach, describe, expect, it, vi } from "vitest";
910

@@ -21,6 +22,13 @@ const mocks = vi.hoisted(() => ({
2122
onPanelLayout: null as null | ((sizes: number[]) => void),
2223
onResizeDragging: null as null | ((isDragging: boolean) => void),
2324
tabContentRenderCount: 0,
25+
devtoolsPanelActionListeners: [] as Array<
26+
(event: { payload: { action: string } }) => void
27+
>,
28+
windowsCommands: {
29+
devtoolsPanelHide: vi.fn(async () => ({ status: "ok" as const })),
30+
devtoolsPanelShow: vi.fn(async () => ({ status: "ok" as const })),
31+
},
2432
}));
2533

2634
vi.mock("@hypr/ui/components/ui/resizable", () => ({
@@ -107,6 +115,25 @@ vi.mock("~/contexts/shell", () => ({
107115
}),
108116
}));
109117

118+
vi.mock("@hypr/plugin-windows", () => ({
119+
commands: mocks.windowsCommands,
120+
events: {
121+
devtoolsPanelAction: {
122+
listen: vi.fn(
123+
async (listener: (event: { payload: { action: string } }) => void) => {
124+
mocks.devtoolsPanelActionListeners.push(listener);
125+
return () => {
126+
mocks.devtoolsPanelActionListeners =
127+
mocks.devtoolsPanelActionListeners.filter(
128+
(candidate) => candidate !== listener,
129+
);
130+
};
131+
},
132+
),
133+
},
134+
},
135+
}));
136+
110137
vi.mock("~/store/zustand/tabs", () => ({
111138
uniqueIdfromTab: (tab: { type: string }) => tab.type,
112139
useTabs: (
@@ -180,6 +207,9 @@ describe("ClassicMainBody", () => {
180207
mocks.onPanelLayout = null;
181208
mocks.onResizeDragging = null;
182209
mocks.tabContentRenderCount = 0;
210+
mocks.devtoolsPanelActionListeners = [];
211+
mocks.windowsCommands.devtoolsPanelHide.mockClear();
212+
mocks.windowsCommands.devtoolsPanelShow.mockClear();
183213
});
184214

185215
it("wraps the expanded left sidebar in a persistent resizable panel", () => {
@@ -369,6 +399,45 @@ describe("ClassicMainBody", () => {
369399
expect(mocks.leftsidebar.toggleExpanded).toHaveBeenCalledTimes(1);
370400
});
371401

402+
it("shows the devtools button until the panel opens, then restores it when closed", async () => {
403+
render(<ClassicMainBody />);
404+
405+
const searchButton = screen.getByRole("button", { name: "Search" });
406+
const newNoteButton = screen.getByRole("button", { name: "New note" });
407+
const devtoolsButton = screen.getByRole("button", {
408+
name: "Show devtools panel",
409+
});
410+
411+
expect(searchButton.compareDocumentPosition(newNoteButton)).toBe(
412+
Node.DOCUMENT_POSITION_FOLLOWING,
413+
);
414+
expect(newNoteButton.compareDocumentPosition(devtoolsButton)).toBe(
415+
Node.DOCUMENT_POSITION_FOLLOWING,
416+
);
417+
expect(devtoolsButton.parentElement).toBe(newNoteButton.parentElement);
418+
419+
fireEvent.click(devtoolsButton);
420+
421+
expect(mocks.windowsCommands.devtoolsPanelShow).toHaveBeenCalledTimes(1);
422+
expect(mocks.windowsCommands.devtoolsPanelHide).not.toHaveBeenCalled();
423+
424+
await waitFor(() => {
425+
expect(
426+
screen.queryByRole("button", { name: "Show devtools panel" }),
427+
).toBeNull();
428+
});
429+
430+
act(() => {
431+
for (const listener of mocks.devtoolsPanelActionListeners) {
432+
listener({ payload: { action: "panel:closed" } });
433+
}
434+
});
435+
436+
expect(
437+
await screen.findByRole("button", { name: "Show devtools panel" }),
438+
).toBeTruthy();
439+
});
440+
372441
it("routes wheel gestures from sidebar chrome into the timeline scroller", () => {
373442
render(<ClassicMainBody />);
374443

apps/desktop/src/main/body.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
PanelLeftOpenIcon,
77
SearchIcon,
88
SquarePenIcon,
9+
WrenchIcon,
910
} from "lucide-react";
1011
import {
1112
type CSSProperties,
@@ -18,6 +19,10 @@ import {
1819
useState,
1920
} from "react";
2021

22+
import {
23+
commands as windowsCommands,
24+
events as windowsEvents,
25+
} from "@hypr/plugin-windows";
2126
import {
2227
ResizableHandle,
2328
ResizablePanel,
@@ -38,6 +43,7 @@ import { useClassicMainTabsShortcuts } from "./useTabsShortcuts";
3843
import { useShell } from "~/contexts/shell";
3944
import { GlobalLiveTranscriptAccessory } from "~/session/components/bottom-accessory/global-live";
4045
import { scrollElementByWheel } from "~/shared/dom/scroll-wheel";
46+
import { useMountEffect } from "~/shared/hooks/useMountEffect";
4147
import { NOTE_SURFACE_MIN_WIDTH_PX } from "~/shared/main/layout-widths";
4248
import { useOpenNoteDialog } from "~/shared/open-note-dialog";
4349
import { useNewNote } from "~/shared/useNewNote";
@@ -54,6 +60,7 @@ const LEFT_SIDEBAR_DEFAULT_WIDTH_PX = 200;
5460
const LEFT_SIDEBAR_MIN_WIDTH_PX = 200;
5561
const LEFT_SIDEBAR_MAX_WIDTH_PX = 360;
5662
const LEFT_SIDEBAR_FALLBACK_CONTAINER_WIDTH_PX = 1000;
63+
const showDevtoolsPanelButton = import.meta.env.DEV;
5764

5865
type MainAreaWindowDragStart = {
5966
pointerId: number;
@@ -81,6 +88,36 @@ export function ClassicMainBody() {
8188
const leftSidebarResizeDraggingRef = useRef(false);
8289
const [showIgnoredTimelineEvents, setShowIgnoredTimelineEvents] =
8390
useState(false);
91+
const [devtoolsPanelOpen, setDevtoolsPanelOpen] = useState(false);
92+
93+
useMountEffect(() => {
94+
if (!showDevtoolsPanelButton) {
95+
return;
96+
}
97+
98+
let cancelled = false;
99+
let unlistenDevtoolsAction: (() => void) | undefined;
100+
101+
windowsEvents.devtoolsPanelAction
102+
.listen(({ payload }) => {
103+
if (payload.action === "panel:closed") {
104+
setDevtoolsPanelOpen(false);
105+
}
106+
})
107+
.then((unlisten) => {
108+
if (cancelled) {
109+
unlisten();
110+
return;
111+
}
112+
113+
unlistenDevtoolsAction = unlisten;
114+
});
115+
116+
return () => {
117+
cancelled = true;
118+
unlistenDevtoolsAction?.();
119+
};
120+
});
84121

85122
const isOnboarding = currentTab?.type === "onboarding";
86123
const isChangelog = currentTab?.type === "changelog";
@@ -117,6 +154,16 @@ export function ClassicMainBody() {
117154
const handleOpenNoteDialog = useCallback(() => {
118155
openNoteDialog.open();
119156
}, [openNoteDialog]);
157+
const handleOpenDevtoolsPanel = useCallback(async () => {
158+
const result = await windowsCommands.devtoolsPanelShow();
159+
160+
if (result.status === "ok") {
161+
setDevtoolsPanelOpen(true);
162+
return;
163+
}
164+
165+
console.error("Failed to show devtools panel:", result.error);
166+
}, []);
120167
const applyLeftSidebarPanelSize = useCallback((size: number) => {
121168
const bodyRoot = bodyRootRef.current;
122169
if (!bodyRoot) {
@@ -241,8 +288,10 @@ export function ClassicMainBody() {
241288
>
242289
<SidebarTimelineChrome
243290
sidebarExpanded={leftsidebar.expanded}
291+
devtoolsPanelOpen={devtoolsPanelOpen}
244292
onNewNote={createNewNote}
245293
onSearch={handleOpenNoteDialog}
294+
onOpenDevtools={handleOpenDevtoolsPanel}
246295
onToggleSidebar={handleToggleLeftSidebar}
247296
hasUpcomingMeeting={hasUpcomingMeetingBadge}
248297
update={update}
@@ -542,15 +591,19 @@ function isMainAreaWindowDrag(
542591
}
543592

544593
function SidebarTimelineChrome({
594+
devtoolsPanelOpen,
545595
hasUpcomingMeeting,
546596
onNewNote,
597+
onOpenDevtools,
547598
onSearch,
548599
onToggleSidebar,
549600
sidebarExpanded,
550601
update,
551602
}: {
603+
devtoolsPanelOpen: boolean;
552604
hasUpcomingMeeting: boolean;
553605
onNewNote: () => void;
606+
onOpenDevtools: () => void;
554607
onSearch: () => void;
555608
onToggleSidebar: () => void;
556609
sidebarExpanded: boolean;
@@ -591,6 +644,14 @@ function SidebarTimelineChrome({
591644
<LeftSurfaceChromeButton ariaLabel="New note" onClick={onNewNote}>
592645
<SquarePenIcon size={15} />
593646
</LeftSurfaceChromeButton>
647+
{showDevtoolsPanelButton && !devtoolsPanelOpen ? (
648+
<LeftSurfaceChromeButton
649+
ariaLabel="Show devtools panel"
650+
onClick={onOpenDevtools}
651+
>
652+
<WrenchIcon size={15} />
653+
</LeftSurfaceChromeButton>
654+
) : null}
594655
</>
595656
) : null}
596657
</div>

apps/desktop/src/test-setup.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ Object.defineProperty(globalThis.window, "__TAURI_INTERNALS__", {
1414
label: "main",
1515
},
1616
},
17+
transformCallback: vi.fn((callback: unknown) => {
18+
const callbackId = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER);
19+
Object.assign(globalThis.window, {
20+
[`_${callbackId}`]: callback,
21+
});
22+
23+
return callbackId;
24+
}),
25+
unregisterCallback: vi.fn((callbackId: number) => {
26+
delete (globalThis.window as unknown as Record<string, unknown>)[
27+
`_${callbackId}`
28+
];
29+
}),
1730
invoke: vi.fn().mockRejectedValue(new Error("not available in test")),
1831
},
1932
writable: true,

plugins/windows/swift-lib/src/DevtoolsPanelManager.swift

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ final class DevtoolsPanelManager {
2727

2828
let panel = self.createPanel()
2929
let hostingView = NSHostingView(
30-
rootView: DevtoolsPanelView { [weak self, weak panel] isCollapsed in
31-
guard let panel else { return }
32-
self?.resize(panel, isCollapsed: isCollapsed)
30+
rootView: DevtoolsPanelView { [weak self] in
31+
self?.hide()
32+
RustBridge.devtoolsPanelAction("panel:closed")
3333
})
3434
hostingView.frame = NSRect(
3535
x: 0,
@@ -100,23 +100,6 @@ final class DevtoolsPanelManager {
100100
}
101101
}
102102

103-
private func resize(_ panel: NSPanel, isCollapsed: Bool) {
104-
let height =
105-
isCollapsed
106-
? DevtoolsPanelLayout.collapsedHeight
107-
: DevtoolsPanelLayout.containerHeight
108-
let size = NSSize(width: DevtoolsPanelLayout.containerWidth, height: height)
109-
targetPanelSize = size
110-
guard abs(panel.frame.height - height) > 0.5 else { return }
111-
112-
let frame = NSRect(
113-
x: panel.frame.minX,
114-
y: panel.frame.maxY - height,
115-
width: size.width,
116-
height: size.height)
117-
placement.setFrame(panel, to: frame, display: true, animate: true)
118-
}
119-
120103
private func startFollowingActiveScreen() {
121104
guard followActiveScreenTimer == nil else { return }
122105

plugins/windows/swift-lib/src/DevtoolsPanelView.swift

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,34 @@ import SwiftUI
33
enum DevtoolsPanelLayout {
44
static let containerWidth: CGFloat = 300
55
static let containerHeight: CGFloat = 560
6-
static let collapsedHeight: CGFloat = 44
76
static let screenMargin: CGFloat = 14
87
}
98

109
struct DevtoolsPanelView: View {
11-
@State private var isCollapsed = false
10+
private let onClose: () -> Void
1211

13-
private let onCollapseChange: (Bool) -> Void
14-
15-
init(onCollapseChange: @escaping (Bool) -> Void = { _ in }) {
16-
self.onCollapseChange = onCollapseChange
12+
init(onClose: @escaping () -> Void = {}) {
13+
self.onClose = onClose
1714
}
1815

1916
var body: some View {
2017
VStack(spacing: 0) {
2118
header
22-
if !isCollapsed {
23-
Divider()
24-
ScrollView(showsIndicators: false) {
25-
VStack(spacing: 10) {
26-
navigationSection
27-
toastsSection
28-
otaSection
29-
notificationsSection
30-
billingSection
31-
countdownSection
32-
errorSection
33-
}
34-
.padding(10)
19+
Divider()
20+
ScrollView(showsIndicators: false) {
21+
VStack(spacing: 10) {
22+
navigationSection
23+
toastsSection
24+
otaSection
25+
notificationsSection
26+
billingSection
27+
countdownSection
28+
errorSection
3529
}
30+
.padding(10)
3631
}
3732
}
38-
.frame(
39-
width: DevtoolsPanelLayout.containerWidth,
40-
height: isCollapsed
41-
? DevtoolsPanelLayout.collapsedHeight : DevtoolsPanelLayout.containerHeight
42-
)
33+
.frame(width: DevtoolsPanelLayout.containerWidth, height: DevtoolsPanelLayout.containerHeight)
4334
.background(
4435
RoundedRectangle(cornerRadius: 12, style: .continuous)
4536
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.96))
@@ -57,13 +48,9 @@ struct DevtoolsPanelView: View {
5748
.foregroundStyle(.primary)
5849
Spacer()
5950
Button {
60-
let nextIsCollapsed = !isCollapsed
61-
withAnimation(.easeInOut(duration: 0.16)) {
62-
isCollapsed = nextIsCollapsed
63-
}
64-
onCollapseChange(nextIsCollapsed)
51+
onClose()
6552
} label: {
66-
Image(systemName: isCollapsed ? "chevron.down" : "chevron.up")
53+
Image(systemName: "xmark")
6754
.font(.system(size: 11, weight: .bold))
6855
.foregroundStyle(.secondary)
6956
.frame(width: 24, height: 22)
@@ -74,7 +61,7 @@ struct DevtoolsPanelView: View {
7461
.contentShape(Rectangle())
7562
}
7663
.buttonStyle(.plain)
77-
.accessibilityLabel(isCollapsed ? "Expand devtools" : "Collapse devtools")
64+
.accessibilityLabel("Close devtools")
7865
}
7966
.padding(.horizontal, 12)
8067
.padding(.vertical, 10)

0 commit comments

Comments
 (0)