diff --git a/package.json b/package.json index 51b22cc..1350286 100644 --- a/package.json +++ b/package.json @@ -512,6 +512,7 @@ "dependencies": { "@iarna/toml": "^2.2.5", "@std/collections": "npm:@jsr/std__collections@^1.0.9", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^5.60.5", "@tanstack/react-table": "^8.20.5", "@vscode-elements/react-elements": "^0.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0657f4b..de17f14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@std/collections': specifier: npm:@jsr/std__collections@^1.0.9 version: '@jsr/std__collections@1.0.9' + '@tanstack/match-sorter-utils': + specifier: ^8.19.4 + version: 8.19.4 '@tanstack/react-query': specifier: ^5.60.5 version: 5.60.5(react@18.3.1) @@ -240,6 +243,10 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tanstack/match-sorter-utils@8.19.4': + resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} + engines: {node: '>=12'} + '@tanstack/query-core@5.60.5': resolution: {integrity: sha512-jiS1aC3XI3BJp83ZiTuDLerTmn9P3U95r6p+6/SNauLJaYxfIC4dMuWygwnBHIZxjn2zJqEpj3nysmPieoxfPQ==} @@ -408,6 +415,9 @@ packages: regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + remove-accents@0.5.0: + resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -591,6 +601,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/match-sorter-utils@8.19.4': + dependencies: + remove-accents: 0.5.0 + '@tanstack/query-core@5.60.5': {} '@tanstack/react-query@5.60.5(react@18.3.1)': @@ -794,6 +808,8 @@ snapshots: regenerator-runtime@0.14.1: {} + remove-accents@0.5.0: {} + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} diff --git a/src/miseService.ts b/src/miseService.ts index 0d3a544..3344613 100644 --- a/src/miseService.ts +++ b/src/miseService.ts @@ -1,3 +1,4 @@ +import { readlink } from "node:fs/promises"; import * as os from "node:os"; import path from "node:path"; import * as toml from "@iarna/toml"; @@ -12,6 +13,7 @@ import { updateBinPath, } from "./configuration"; import { expandPath } from "./utils/fileUtils"; +import { uniqBy } from "./utils/fn"; import { logger } from "./utils/logger"; import { resolveMisePath } from "./utils/miseBinLocator"; import { type MiseConfig, parseMiseConfig } from "./utils/miseDoctorParser"; @@ -19,6 +21,13 @@ import { showSettingsNotification } from "./utils/notify"; import { execAsync, execAsyncMergeOutput } from "./utils/shell"; import { type MiseTaskInfo, parseTaskInfo } from "./utils/taskInfoParser"; +// https://github.com/jdx/mise/blob/main/src/env.rs +const XDG_STATE_HOME = + process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state"); +const STATE_DIR = + process.env.MISE_STATE_DIR ?? path.join(XDG_STATE_HOME, "mise"); +const TRACKED_CONFIG_DIR = path.join(STATE_DIR, "tracked-configs"); + const MIN_MISE_VERSION = [2024, 11, 4] as const; function ensureMiseCommand( @@ -644,6 +653,47 @@ export class MiseService { return toml.parse(stdout); } + async getTrackedConfigFiles() { + const trackedConfigFiles = await vscode.workspace.fs.readDirectory( + vscode.Uri.file(TRACKED_CONFIG_DIR), + ); + + const parsedTrackedConfigs = await Promise.all( + trackedConfigFiles.map(async ([n]) => { + try { + const trackedConfigPath = await readlink( + path.join(TRACKED_CONFIG_DIR, n), + ).catch(() => ""); + if (!trackedConfigPath) { + return {}; + } + + const stats = await vscode.workspace.fs.stat( + vscode.Uri.file(trackedConfigPath), + ); + + if (stats.type === vscode.FileType.File) { + const content = await vscode.workspace.fs.readFile( + vscode.Uri.file(trackedConfigPath), + ); + const config = toml.parse(content.toString()); + return { path: trackedConfigPath, tools: config.tools ?? {} }; + } + } catch { + return {}; + } + }), + ); + + const validConfigs = parsedTrackedConfigs.filter( + (trackedConfigPath) => trackedConfigPath?.path, + ) as Array<{ path: string; tools: object }>; + + return uniqBy(validConfigs, (c) => c.path).sort((a, b) => + a.path.localeCompare(b.path), + ); + } + dispose() { if (this.terminal) { this.terminal.dispose(); diff --git a/src/utils/fn.ts b/src/utils/fn.ts new file mode 100644 index 0000000..bc53064 --- /dev/null +++ b/src/utils/fn.ts @@ -0,0 +1,16 @@ +export function uniqBy(array: T[], iteratee: (value: T) => string): T[] { + if (!Array.isArray(array)) { + throw new TypeError("Expected an array as first argument"); + } + + const seen = new Map(); + + for (const item of array) { + const key = iteratee(item); + if (!seen.has(key)) { + seen.set(key, item); + } + } + + return Array.from(seen.values()); +} diff --git a/src/webviewPanel.ts b/src/webviewPanel.ts index f8acb71..7eb0689 100644 --- a/src/webviewPanel.ts +++ b/src/webviewPanel.ts @@ -14,6 +14,7 @@ export default class WebViewPanel { private readonly _extContext: vscode.ExtensionContext; private _disposables: vscode.Disposable[] = []; private readonly miseService: MiseService; + private _currentPath = "tools"; public static createOrShow( extContext: vscode.ExtensionContext, @@ -38,16 +39,22 @@ export default class WebViewPanel { _extContext: vscode.ExtensionContext, column: vscode.ViewColumn, miseService: MiseService, + initialPath?: string, ) { this._extContext = _extContext; this._extensionUri = _extContext.extensionUri; this.miseService = miseService; + this._currentPath = initialPath ?? "tools"; this._panel = vscode.window.createWebviewPanel( WebViewPanel.viewType, "Mise", column, - { enableScripts: true, localResourceRoots: [this._extensionUri] }, + { + retainContextWhenHidden: true, + enableScripts: true, + localResourceRoots: [this._extensionUri], + }, ); this._panel.webview.html = this._getHtmlForWebview(this._panel.webview); @@ -77,6 +84,10 @@ export default class WebViewPanel { this._panel.webview.onDidReceiveMessage( async (message) => { switch (message.type) { + case "updateState": { + this._currentPath = message.path; + break; + } case "query": switch (message.queryKey[0]) { case "tools": { @@ -94,6 +105,11 @@ export default class WebViewPanel { this.miseService.getSettings(), ); } + case "trackedConfigs": { + return executeAction(message, () => + this.miseService.getTrackedConfigFiles(), + ); + } } break; case "mutation": @@ -106,6 +122,14 @@ export default class WebViewPanel { ), ); } + case "openFile": { + return executeAction(message, async () => + vscode.window.showTextDocument( + vscode.Uri.file(message.variables?.path as string), + { preview: true, viewColumn: vscode.ViewColumn.One }, + ), + ); + } } break; } @@ -174,6 +198,9 @@ export default class WebViewPanel { } }); + const $head = $("head"); + $head.append(``); + const codiconsUri = webview.asWebviewUri( vscode.Uri.joinPath( this._extensionUri, @@ -183,7 +210,7 @@ export default class WebViewPanel { "codicon.css", ), ); - $("head").append( + $head.append( ``, ); diff --git a/src/webviews/App.tsx b/src/webviews/App.tsx index 026e502..532c4ef 100644 --- a/src/webviews/App.tsx +++ b/src/webviews/App.tsx @@ -1,11 +1,19 @@ import { Settings } from "./Settings"; import { Tools } from "./Tools"; +import { TrackedConfigs } from "./TrackedConfigs"; import { Tabs } from "./components/Tabs"; export function App() { + const initialPathMeta = document.querySelector('meta[name="initial-path"]'); + return (
, icon: "codicon codicon-settings", }, + { + name: "Config files", + content: , + icon: "codicon codicon-file", + }, ]} />
diff --git a/src/webviews/Settings.tsx b/src/webviews/Settings.tsx index e38a805..06d6627 100644 --- a/src/webviews/Settings.tsx +++ b/src/webviews/Settings.tsx @@ -15,7 +15,7 @@ export const Settings = () => { }); const schemaQuery = useQuery({ - queryKey: ["settingsSchema2"], + queryKey: ["settingsSchema"], queryFn: async () => { const res = await fetch( "https://raw.githubusercontent.com/jdx/mise/refs/heads/main/schema/mise.json", @@ -60,14 +60,14 @@ export const Settings = () => { return (
-
- setShowModifiedOnly(!showModifiedOnly)} - label="Show modified only" - checked={showModifiedOnly ? true : undefined} - /> -
setShowModifiedOnly(!showModifiedOnly)} + label="Show modified only" + checked={showModifiedOnly ? true : undefined} + /> + } isLoading={settingsQuery.isLoading} data={settingValues.filter( (value) => diff --git a/src/webviews/Tools.tsx b/src/webviews/Tools.tsx index 7a82d76..28666b1 100644 --- a/src/webviews/Tools.tsx +++ b/src/webviews/Tools.tsx @@ -53,6 +53,9 @@ export const Tools = () => { return (
{ ]} data={toolsQuery?.data || []} /> - {outdatedToolsQuery.isLoading ? "Loading outdated tools..." : ""}
); }; diff --git a/src/webviews/TrackedConfigs.tsx b/src/webviews/TrackedConfigs.tsx new file mode 100644 index 0000000..33345af --- /dev/null +++ b/src/webviews/TrackedConfigs.tsx @@ -0,0 +1,68 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import CustomTable from "./components/CustomTable"; +import { vscodeClient } from "./webviewVsCodeApi"; + +export const TrackedConfigs = () => { + const settingsQuery = useQuery({ + queryKey: ["trackedConfigs"], + queryFn: ({ queryKey }) => + vscodeClient.request({ queryKey }) as Promise< + Array<{ path: string; tools: object }> + >, + }); + + const openFileMutation = useMutation({ + mutationKey: ["openFile"], + mutationFn: (path: string) => + vscodeClient.request({ mutationKey: ["openFile"], variables: { path } }), + }); + + if (settingsQuery.isError) { + return
Error: {settingsQuery.error.message}
; + } + + return ( +
+ { + return ( + { + e.preventDefault(); + openFileMutation.mutate(row.original.path); + }} + title={row.original.path} + > + {row.original.path} + + ); + }, + }, + { + id: "tools", + header: "Tools", + accessorKey: "tools", + cell: ({ row }) => { + return Object.entries(row.original.tools).map( + ([toolName, toolVersion]) => ( +
+ {toolName} = {JSON.stringify(toolVersion)} +
+ ), + ); + }, + }, + ]} + /> +
+ ); +}; diff --git a/src/webviews/components/CustomTable.tsx b/src/webviews/components/CustomTable.tsx index 4996574..f5d2ed0 100644 --- a/src/webviews/components/CustomTable.tsx +++ b/src/webviews/components/CustomTable.tsx @@ -1,12 +1,16 @@ import { type ColumnDef, + type FilterFn, type SortingState, flexRender, getCoreRowModel, + getFilteredRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; +import { rankItem } from "@tanstack/match-sorter-utils"; + import { VscodeTable, VscodeTableBody, @@ -15,28 +19,41 @@ import { VscodeTableHeaderCell, VscodeTableRow, } from "@vscode-elements/react-elements"; -import { useState } from "react"; +import { type ReactElement, useState } from "react"; +import { DebouncedInput } from "./DebouncedInput"; type VSCodeTableProps = { data: T[]; columns: ColumnDef[]; isLoading?: boolean; + filterRowElement?: ReactElement | string; }; export default function CustomTable({ data, columns, isLoading = false, + filterRowElement, }: VSCodeTableProps) { const [sorting, setSorting] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + + const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value); + addMeta({ itemRank }); + return itemRank.passed; + }; const table = useReactTable({ data, columns, - state: { sorting }, + state: { sorting, globalFilter }, + onGlobalFilterChange: setGlobalFilter, + getFilteredRowModel: getFilteredRowModel(), onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + globalFilterFn: fuzzyFilter, }); if (isLoading) { @@ -56,56 +73,73 @@ export default function CustomTable({ } return ( - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : ( - - )} - - ))} - - ))} - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - +
+ { + setGlobalFilter(value); + }} + placeholder="Search" + /> + {filterRowElement} +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : ( + + )} + ))} -
+ ))} -
-
+ + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + +
); } diff --git a/src/webviews/components/DebouncedInput.tsx b/src/webviews/components/DebouncedInput.tsx new file mode 100644 index 0000000..ac13439 --- /dev/null +++ b/src/webviews/components/DebouncedInput.tsx @@ -0,0 +1,37 @@ +import { VscodeTextfield } from "@vscode-elements/react-elements"; +import { type InputHTMLAttributes, useEffect, useState } from "react"; + +export function DebouncedInput({ + value: initialValue, + onChange, + debounce = 100, + ...props +}: { + value: string | number; + onChange: (value: string | number) => void; + debounce?: number; +} & Omit, "onChange">) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value); + }, debounce); + + return () => clearTimeout(timeout); + }, [value, debounce, onChange]); + + return ( + setValue(e.target.value)} + /> + ); +} diff --git a/src/webviews/components/Tabs.tsx b/src/webviews/components/Tabs.tsx index 97b50ab..babcc66 100644 --- a/src/webviews/components/Tabs.tsx +++ b/src/webviews/components/Tabs.tsx @@ -1,18 +1,29 @@ import { type ReactNode, useState } from "react"; +import { vscode } from "../webviewVsCodeApi"; export function Tabs({ tabs, + initialTab, }: { + initialTab?: string; tabs: Array<{ name: string; icon?: string; content: ReactNode }>; }) { - const [selectedTab, setSelectedTab] = useState(tabs[0]?.name ?? ""); + const [selectedTab, setSelectedTab] = useState( + initialTab ?? tabs[0]?.name ?? "", + ); return (