Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions windows/tauri/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.3.0"
description = "Lithe Windows desktop application"
edition = "2021"
license = "Apache-2.0"
default-run = "lithe-windows"

[features]
test-support = []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test";

test("run configuration list split wires keyboard resize and drag session cleanup", async () => {
const source = await Bun.file(
new URL("./run-configuration-list-split.tsx", import.meta.url),
).text();

expect(source).toContain("onKeyDown={handleKeyDown}");
expect(source).toContain("nextRunConfigurationListWidthForKey");
expect(source).toContain("startDocumentResizeSession");
expect(source).toContain('sessionRef.current?.dispose({ commit: true })');
expect(source).toContain("onPointerDown={handlePointerDown}");
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type React from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "@/i18n/locale-provider";
import { cn } from "@/utils/cn";
import { useRunPreferencesStore } from "../stores/run-preferences.store";
import {
RUN_CONFIGURATION_LIST_HANDLE_THICKNESS,
RUN_CONFIGURATION_LIST_MIN_WIDTH,
clampRunConfigurationListWidth,
getRunConfigurationListMaxWidth,
} from "../utils/run-configuration-list-layout";
import {
nextRunConfigurationListWidthForKey,
startDocumentResizeSession,
type DocumentResizeSession,
} from "../utils/run-configuration-list-resize-session";

interface RunConfigurationListSplitProps {
list: React.ReactNode;
content: React.ReactNode;
}

/**
* Local layout container for the Run tool window's configuration list.
* Keeps drag width mutations out of RunPane and only commits when a resize
* session ends, matching macOS LitheSplitPaneView persistence.
*/
export function RunConfigurationListSplit({ list, content }: RunConfigurationListSplitProps) {
const { t } = useTranslation();
const storedWidth = useRunPreferencesStore((state) => state.configurationListWidth);
const setConfigurationListWidth = useRunPreferencesStore(
(state) => state.actions.setConfigurationListWidth,
);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const sessionRef = useRef<DocumentResizeSession | null>(null);
const isMountedRef = useRef(true);
const [containerWidth, setContainerWidth] = useState(0);
const [width, setWidth] = useState(() =>
clampRunConfigurationListWidth(storedWidth, typeof window !== "undefined" ? window.innerWidth : 1280),
);
const [isResizing, setIsResizing] = useState(false);

const clampWidth = useCallback(
(value: number) => clampRunConfigurationListWidth(value, containerWidth || 1280),
[containerWidth],
);

const commitWidth = useCallback(
(nextWidth: number) => {
setConfigurationListWidth(nextWidth);
if (isMountedRef.current) {
setWidth(nextWidth);
setIsResizing(false);
}
sessionRef.current = null;
},
[setConfigurationListWidth],
);

useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;

const updateWidth = () => {
const nextContainerWidth = container.getBoundingClientRect().width;
setContainerWidth(nextContainerWidth);
setWidth((current) => clampRunConfigurationListWidth(current, nextContainerWidth));
};

updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(container);
return () => observer.disconnect();
}, []);

useEffect(() => {
setWidth(clampWidth(storedWidth));
}, [storedWidth, clampWidth]);

useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
// Persist the in-flight width, then drop listeners/body styles/RAF.
sessionRef.current?.dispose({ commit: true });
sessionRef.current = null;
};
}, []);

const handlePointerDown = useCallback(
(event: React.PointerEvent) => {
event.preventDefault();
sessionRef.current?.dispose({ commit: true });

const listEl = listRef.current;
sessionRef.current = startDocumentResizeSession({
startX: event.clientX,
startWidth: width,
clampWidth,
applyWidth: (nextWidth) => {
if (listEl) {
listEl.style.width = `${nextWidth}px`;
}
},
commitWidth,
onActiveChange: (active) => {
if (isMountedRef.current) {
setIsResizing(active);
}
},
});
},
[width, clampWidth, commitWidth],
);

const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const nextWidth = nextRunConfigurationListWidthForKey(
width,
event.key,
containerWidth || 1280,
);
if (nextWidth == null) {
return;
}

event.preventDefault();
setWidth(nextWidth);
setConfigurationListWidth(nextWidth);
},
[width, containerWidth, setConfigurationListWidth],
);

const minWidth = Math.min(
RUN_CONFIGURATION_LIST_MIN_WIDTH,
getRunConfigurationListMaxWidth(containerWidth || 1280),
);
const maxWidth = getRunConfigurationListMaxWidth(containerWidth || 1280);

return (
<div ref={containerRef} className="flex min-h-0 min-w-0 flex-1">
<div
ref={listRef}
style={{ width: `${width}px` }}
className="relative flex min-h-0 shrink-0 flex-col border-border/70 border-r"
>
{list}
<div
onPointerDown={handlePointerDown}
onKeyDown={handleKeyDown}
style={{ width: RUN_CONFIGURATION_LIST_HANDLE_THICKNESS }}
className={cn(
"group absolute top-0 right-0 z-20 flex h-full translate-x-1/2 cursor-col-resize items-center justify-center",
"transition-colors duration-(--app-duration-fast) ease-(--app-ease-smooth) hover:bg-primary/8",
)}
role="separator"
Comment thread
1lck marked this conversation as resolved.
aria-orientation="vertical"
aria-label={t("run.resizeConfigurationList")}
aria-valuenow={Math.round(width)}
aria-valuemin={Math.round(minWidth)}
aria-valuemax={Math.round(maxWidth)}
tabIndex={0}
>
<div
className={cn(
"h-full w-px bg-transparent transition-colors duration-(--app-duration-fast) ease-(--app-ease-smooth) group-hover:bg-primary",
isResizing && "bg-primary",
)}
/>
</div>
</div>
{isResizing ? <div className="fixed inset-0 z-40 cursor-col-resize" /> : null}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">{content}</div>
</div>
);
}
129 changes: 68 additions & 61 deletions windows/tauri/src/features/run/components/run-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
workspaceRelativePath,
} from "../utils/run-configuration";
import { RunConfigurationEditor } from "./run-configuration-editor";
import { RunConfigurationListSplit } from "./run-configuration-list-split";
import { JavaCupIcon, RunIcon } from "./run-icon";
import { RunOutputText } from "./run-output-text";

Expand Down Expand Up @@ -191,68 +192,74 @@ export default function RunPane() {
) : null}
</div>
) : (
<div className="flex min-h-0 flex-1">
<div className="flex w-56 shrink-0 flex-col border-border/70 border-r">
<div className="px-3 py-2 font-medium text-subtle-foreground ui-text-sm">{t("run.configurations")}</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
<ConfigurationSection
title={t("run.services")}
configurations={services}
selectedId={selectedConfigurationId}
sessions={sessions}
onSelect={actions.selectConfiguration}
onRun={(configuration) => void actions.runConfiguration(configuration.id, currentFile)}
onEdit={setEditingId}
/>
<ConfigurationSection
title={t("run.applications")}
configurations={applications}
selectedId={selectedConfigurationId}
sessions={sessions}
onSelect={actions.selectConfiguration}
onRun={(configuration) => void actions.runConfiguration(configuration.id, currentFile)}
onEdit={setEditingId}
/>
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="border-border/70 border-b px-3 py-2">
<div className="font-medium text-subtle-foreground ui-text-sm">{t("run.configurationDetails")}</div>
{selectedConfiguration ? (
<div className="mt-1 grid grid-cols-[6.5rem_1fr] gap-y-0.5 ui-text-sm">
<span className="text-subtle-foreground">{t("run.type")}</span>
<span>{selectedConfiguration.kindTitle}</span>
{selectedConfiguration.mainClass ? (
<>
<span className="text-subtle-foreground">{t("run.mainClass")}</span>
<span className="truncate font-mono">{selectedConfiguration.mainClass}</span>
</>
) : null}
</div>
) : (
<div className="mt-1 text-subtle-foreground ui-text-sm">{t("run.selectConfiguration")}</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto px-3 py-2">
<RunOutputText
title={t("run.processOutput")}
source={output}
emptyLabel={t("run.emptyOutput")}
/>
</div>
{isSelectedRunning ? (
<RunStdinInput
sessionId={selectedSessionId ?? PRIMARY_SESSION_ID}
onSend={(input) => void actions.writeStdin(selectedSessionId ?? PRIMARY_SESSION_ID, input)}
/>
) : null}
{generationNotice?.startsWith("generated:") ? (
<div className="border-border/70 border-t px-3 py-1.5 text-subtle-foreground ui-text-sm">
{t("run.generatedEntries", { count: generationNotice.slice("generated:".length) })}
<RunConfigurationListSplit
list={
<>
<div className="px-3 py-2 font-medium text-subtle-foreground ui-text-sm">
{t("run.configurations")}
</div>
) : null}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
<ConfigurationSection
title={t("run.services")}
configurations={services}
selectedId={selectedConfigurationId}
sessions={sessions}
onSelect={actions.selectConfiguration}
onRun={(configuration) => void actions.runConfiguration(configuration.id, currentFile)}
onEdit={setEditingId}
/>
<ConfigurationSection
title={t("run.applications")}
configurations={applications}
selectedId={selectedConfigurationId}
sessions={sessions}
onSelect={actions.selectConfiguration}
onRun={(configuration) => void actions.runConfiguration(configuration.id, currentFile)}
onEdit={setEditingId}
/>
</div>
</>
}
content={
<>
<div className="border-border/70 border-b px-3 py-2">
<div className="font-medium text-subtle-foreground ui-text-sm">{t("run.configurationDetails")}</div>
{selectedConfiguration ? (
<div className="mt-1 grid grid-cols-[6.5rem_1fr] gap-y-0.5 ui-text-sm">
<span className="text-subtle-foreground">{t("run.type")}</span>
<span>{selectedConfiguration.kindTitle}</span>
{selectedConfiguration.mainClass ? (
<>
<span className="text-subtle-foreground">{t("run.mainClass")}</span>
<span className="truncate font-mono">{selectedConfiguration.mainClass}</span>
</>
) : null}
</div>
) : (
<div className="mt-1 text-subtle-foreground ui-text-sm">{t("run.selectConfiguration")}</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto px-3 py-2">
<RunOutputText
title={t("run.processOutput")}
source={output}
emptyLabel={t("run.emptyOutput")}
/>
</div>
{isSelectedRunning ? (
<RunStdinInput
sessionId={selectedSessionId ?? PRIMARY_SESSION_ID}
onSend={(input) => void actions.writeStdin(selectedSessionId ?? PRIMARY_SESSION_ID, input)}
/>
) : null}
{generationNotice?.startsWith("generated:") ? (
<div className="border-border/70 border-t px-3 py-1.5 text-subtle-foreground ui-text-sm">
{t("run.generatedEntries", { count: generationNotice.slice("generated:".length) })}
</div>
) : null}
</>
}
/>
)}

{editingConfiguration ? (
Expand Down
35 changes: 35 additions & 0 deletions windows/tauri/src/features/run/stores/run-preferences.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createSelectors } from "@/utils/zustand-selectors";
import { createSafeJSONStorage } from "@/utils/zustand-storage";
import { RUN_CONFIGURATION_LIST_DEFAULT_WIDTH } from "../utils/run-configuration-list-layout";

interface RunPreferencesStore {
configurationListWidth: number;
actions: {
setConfigurationListWidth: (width: number) => void;
};
}

const useRunPreferencesStoreBase = create<RunPreferencesStore>()(
persist(
(set) => ({
configurationListWidth: RUN_CONFIGURATION_LIST_DEFAULT_WIDTH,
actions: {
setConfigurationListWidth: (configurationListWidth) => set({ configurationListWidth }),
},
}),
{
name: "lithe-run-preferences",
storage: createSafeJSONStorage<Omit<RunPreferencesStore, "actions">>(),
partialize: ({ actions: _, ...preferences }) => preferences,
merge: (persistedState, currentState) => ({
...currentState,
...(persistedState as Partial<RunPreferencesStore>),
actions: currentState.actions,
}),
},
),
);

export const useRunPreferencesStore = createSelectors(useRunPreferencesStoreBase);
Loading
Loading