Skip to content
Open
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: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ Comments explain what code does or why it exists:
- All unit tests: `pnpm test`
- Extension tests: `pnpm test:extension`
- Webview tests: `pnpm test:webview`
- CI mode: `pnpm test:ci`
- Integration tests: `pnpm test:integration`
- Run specific extension test: `pnpm test:extension ./test/unit/filename.test.ts`
- Run specific webview test: `pnpm test:webview ./test/webview/filename.test.ts`
Expand Down
3 changes: 1 addition & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ The project uses Vitest with separate test configurations for extension and webv
```bash
pnpm test:extension # Extension tests (runs in Electron with mocked VS Code APIs)
pnpm test:webview # Webview tests (runs in jsdom)
pnpm test # Both extension and webview tests
pnpm test:ci # CI mode (same as test with CI=true)
pnpm test # Both extension and webview tests (CI mode)
```

Test files are organized by type:
Expand Down
18 changes: 12 additions & 6 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { createTypeScriptImportResolver } from "eslint-import-resolver-typescrip
import { flatConfigs as importXFlatConfigs } from "eslint-plugin-import-x";
import packageJson from "eslint-plugin-package-json";
import reactPlugin from "eslint-plugin-react";
import reactCompilerPlugin from "eslint-plugin-react-compiler";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import globals from "globals";

Expand Down Expand Up @@ -177,24 +176,31 @@ export default defineConfig(
},
},

// TSX files - React rules
// React hooks and compiler rules (covers .ts hook files too)
{
files: ["packages/**/*.{ts,tsx}"],
...reactHooksPlugin.configs.flat.recommended,
rules: {
...reactHooksPlugin.configs.flat.recommended.rules,
// React Compiler auto-memoizes; exhaustive-deps false-positives on useCallback
"react-hooks/exhaustive-deps": "off",
},
},

// TSX files - React JSX rules
{
files: ["**/*.tsx"],
plugins: {
react: reactPlugin,
"react-compiler": reactCompilerPlugin,
"react-hooks": reactHooksPlugin,
},
settings: {
react: {
version: "detect",
},
},
rules: {
...reactCompilerPlugin.configs.recommended.rules,
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs["jsx-runtime"].rules, // React 17+ JSX transform
...reactHooksPlugin.configs.recommended.rules,
"react/prop-types": "off", // Using TypeScript
},
},
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
"package": "vsce package --no-dependencies",
"package:prerelease": "vsce package --pre-release --no-dependencies",
"test": "CI=true pnpm test:extension && CI=true pnpm test:webview",
"test:ci": "pnpm test",
"test:extension": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension",
"test:integration": "tsc -p test --outDir out --noCheck && node esbuild.mjs && vscode-test",
"test:webview": "vitest --project webview",
Expand Down Expand Up @@ -496,6 +495,7 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@eslint/markdown": "^7.5.1",
"@tanstack/react-query": "catalog:",
"@testing-library/react": "^16.3.2",
"@tsconfig/node20": "^20.1.9",
"@types/mocha": "^10.0.10",
Expand Down Expand Up @@ -527,8 +527,7 @@
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-package-json": "^0.88.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "catalog:",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "catalog:",
"globals": "^17.3.0",
"jsdom": "^28.0.0",
"jsonc-eslint-parser": "^2.4.2",
Expand Down
117 changes: 90 additions & 27 deletions packages/tasks/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,109 @@
import { useQuery } from "@tanstack/react-query";
import { TasksApi } from "@repo/shared";
import { useIpc } from "@repo/webview-shared/react";
import {
VscodeButton,
VscodeIcon,
VscodeCollapsible,
VscodeProgressRing,
VscodeScrollable,
} from "@vscode-elements/react-elements";
import { useEffect, useRef } from "react";

import { useTasksApi } from "./hooks/useTasksApi";
import {
CreateTaskSection,
ErrorState,
NoTemplateState,
NotSupportedState,
TaskList,
} from "./components";
import { useCollapsibleToggle } from "./hooks/useCollapsibleToggle";
import { useScrollableHeight } from "./hooks/useScrollableHeight";
import { useTasksData } from "./hooks/useTasksData";

type CollapsibleElement = React.ComponentRef<typeof VscodeCollapsible>;
type ScrollableElement = React.ComponentRef<typeof VscodeScrollable>;

export default function App() {
const api = useTasksApi();
const {
tasks,
templates,
tasksSupported,
isLoading,
error,
refetch,
initialCreateExpanded,
initialHistoryExpanded,
persistUiState,
} = useTasksData();

const { data, isLoading, error, refetch } = useQuery({
queryKey: ["tasks-init"],
queryFn: () => api.init(),
});
const [createRef, createOpen, setCreateOpen] =
useCollapsibleToggle<CollapsibleElement>(initialCreateExpanded);
const [historyRef, historyOpen, _setHistoryOpen] =
useCollapsibleToggle<CollapsibleElement>(initialHistoryExpanded);

if (isLoading) {
return <VscodeProgressRing />;
}
const createScrollRef = useRef<ScrollableElement>(null);
const historyScrollRef = useRef<ScrollableElement>(null);
useScrollableHeight(createRef, createScrollRef);
useScrollableHeight(historyRef, historyScrollRef);

if (error) {
return <p>Error: {error.message}</p>;
const { onNotification } = useIpc();
useEffect(() => {
return onNotification(TasksApi.showCreateForm, () => setCreateOpen(true));
}, [onNotification, setCreateOpen]);

useEffect(() => {
persistUiState({
createExpanded: createOpen,
historyExpanded: historyOpen,
});
}, [createOpen, historyOpen, persistUiState]);

if (isLoading) {
return (
<div className="loading-container">
<VscodeProgressRing />
</div>
);
}

if (!data?.tasksSupported) {
if (error && tasks.length === 0) {
return (
<p>
<VscodeIcon name="warning" /> Tasks not supported
</p>
<ErrorState message={error.message} onRetry={() => void refetch()} />
);
}

if (!tasksSupported) {
return <NotSupportedState />;
}

if (templates.length === 0) {
return <NoTemplateState />;
}

return (
<div>
<p>
<VscodeIcon name="check" /> Connected to {data.baseUrl}
</p>
<p>Templates: {data.templates.length}</p>
<p>Tasks: {data.tasks.length}</p>
<VscodeButton icon="refresh" onClick={() => void refetch()}>
Refresh
</VscodeButton>
<div className="tasks-panel">
<VscodeCollapsible
ref={createRef}
heading="Create new task"
open={createOpen}
>
<VscodeScrollable ref={createScrollRef}>
<CreateTaskSection templates={templates} />
</VscodeScrollable>
</VscodeCollapsible>

<VscodeCollapsible
ref={historyRef}
heading="Task History"
open={historyOpen}
>
<VscodeScrollable ref={historyScrollRef}>
<TaskList
tasks={tasks}
onSelectTask={(_taskId: string) => {
// Task detail view will be added in next PR
}}
/>
</VscodeScrollable>
</VscodeCollapsible>
</div>
);
}
139 changes: 139 additions & 0 deletions packages/tasks/src/components/ActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// VscodeContextMenu is data-driven with { label, value, separator }[] and lacks
// support for icons, per-item danger styling, loading spinners, and disabled states.
import {
VscodeIcon,
VscodeProgressRing,
} from "@vscode-elements/react-elements";
import { useState, useRef, useEffect, useCallback } from "react";

interface ActionMenuAction {
separator?: false;
label: string;
icon: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
loading?: boolean;
}

interface ActionMenuSeparator {
separator: true;
}

export type ActionMenuItem = ActionMenuAction | ActionMenuSeparator;

interface ActionMenuProps {
items: ActionMenuItem[];
}

export function ActionMenu({ items }: ActionMenuProps) {
const [position, setPosition] = useState<{
top: number;
right: number;
} | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);

function toggle() {
setPosition((prev) => {
if (prev) {
return null;
}
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) {
return null;
}
return { top: rect.bottom, right: window.innerWidth - rect.right };
});
}

const isOpen = position !== null;

const dropdownRefCallback = useCallback((node: HTMLDivElement | null) => {
dropdownRef.current = node;
node?.focus();
}, []);

function onKeyDown(event: React.KeyboardEvent) {
if (event.key === "Escape") {
setPosition(null);
}
}

useEffect(() => {
if (!isOpen) return;

const close = () => setPosition(null);

function onMouseDown(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
close();
}
}

document.addEventListener("mousedown", onMouseDown);
window.addEventListener("scroll", close, true);

return () => {
document.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("scroll", close, true);
};
}, [isOpen]);

return (
<div className="action-menu" ref={menuRef}>
<div ref={buttonRef}>
<VscodeIcon
actionIcon
name="ellipsis"
label="More actions"
onClick={toggle}
/>
</div>
{position && (
<div
ref={dropdownRefCallback}
className="action-menu-dropdown"
style={position}
tabIndex={-1}
onKeyDown={onKeyDown}
>
{items.map((item, index) =>
item.separator ? (
<div
key={`sep-${index}`}
className="action-menu-separator"
role="separator"
/>
) : (
<button
key={`${item.label}-${index}`}
type="button"
className={[
"action-menu-item",
item.danger && "danger",
item.loading && "loading",
]
.filter(Boolean)
.join(" ")}
onClick={() => {
item.onClick();
setPosition(null);
}}
disabled={item.disabled === true || item.loading === true}
>
{item.loading ? (
<VscodeProgressRing className="action-menu-spinner" />
) : (
<VscodeIcon name={item.icon} className="action-menu-icon" />
)}
<span>{item.label}</span>
</button>
),
)}
</div>
)}
</div>
);
}
Loading