mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-24 03:24:54 +01:00
Compare commits
4 Commits
v2025.7.0-
...
v2025.7.0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8300187566 | ||
|
|
cd8ab3616e | ||
|
|
be0c92b755 | ||
|
|
c34ea20406 |
@@ -12,6 +12,7 @@
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev",
|
||||
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
|
||||
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx",
|
||||
"test": "vitest --run tests"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetchAccessToken } from '../fetchAccessToken';
|
||||
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
|
||||
import type { AccessToken, TokenStoreArgs } from '../store';
|
||||
import { getDataDirKey, storeToken } from '../store';
|
||||
import { extractCode } from '../util';
|
||||
|
||||
export const PKCE_SHA256 = 'S256';
|
||||
export const PKCE_PLAIN = 'plain';
|
||||
@@ -79,7 +80,6 @@ export async function getAuthorizationCode(
|
||||
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
|
||||
}
|
||||
|
||||
const logsEnabled = (await ctx.store.get('enable_logs')) ?? false;
|
||||
const dataDirKey = await getDataDirKey(ctx, contextId);
|
||||
const authorizationUrlStr = authorizationUrl.toString();
|
||||
console.log('[oauth2] Authorizing', authorizationUrlStr);
|
||||
@@ -97,18 +97,17 @@ export async function getAuthorizationCode(
|
||||
}
|
||||
},
|
||||
async onNavigate({ url: urlStr }) {
|
||||
const url = new URL(urlStr);
|
||||
if (logsEnabled) console.log('[oauth2] Navigated to', urlStr);
|
||||
|
||||
if (url.searchParams.has('error')) {
|
||||
let code;
|
||||
try {
|
||||
code = extractCode(urlStr, redirectUri);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
close();
|
||||
return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
console.log('[oauth2] Code not found');
|
||||
return; // Could be one of many redirects in a chain, so skip it
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the window here, because we don't need it anymore!
|
||||
|
||||
@@ -6,8 +6,8 @@ import type {
|
||||
PluginDefinition,
|
||||
} from '@yaakapp/api';
|
||||
import {
|
||||
genPkceCodeVerifier,
|
||||
DEFAULT_PKCE_METHOD,
|
||||
genPkceCodeVerifier,
|
||||
getAuthorizationCode,
|
||||
PKCE_PLAIN,
|
||||
PKCE_SHA256,
|
||||
@@ -125,17 +125,6 @@ export const plugin: PluginDefinition = {
|
||||
await resetDataDirKey(ctx, contextId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle Debug Logs',
|
||||
async onSelect(ctx) {
|
||||
const enableLogs = !(await ctx.store.get('enable_logs'));
|
||||
await ctx.store.set('enable_logs', enableLogs);
|
||||
await ctx.toast.show({
|
||||
message: `Debug logs ${enableLogs ? 'enabled' : 'disabled'}`,
|
||||
color: 'info',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
args: [
|
||||
{
|
||||
|
||||
@@ -3,3 +3,83 @@ import type { AccessToken } from './store';
|
||||
export function isTokenExpired(token: AccessToken) {
|
||||
return token.expiresAt && Date.now() > token.expiresAt;
|
||||
}
|
||||
|
||||
export function extractCode(urlStr: string, redirectUri: string | null): string | null {
|
||||
const url = new URL(urlStr);
|
||||
|
||||
if (!urlMatchesRedirect(url, redirectUri)) {
|
||||
console.log('[oauth2] URL does not match redirect origin/path; skipping.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer query param; fall back to fragment if query lacks it
|
||||
|
||||
const query = url.searchParams;
|
||||
const queryError = query.get('error');
|
||||
const queryDesc = query.get('error_description');
|
||||
const queryUri = query.get('error_uri');
|
||||
|
||||
let hashParams: URLSearchParams | null = null;
|
||||
if (url.hash && url.hash.length > 1) {
|
||||
hashParams = new URLSearchParams(url.hash.slice(1));
|
||||
}
|
||||
const hashError = hashParams?.get('error');
|
||||
const hashDesc = hashParams?.get('error_description');
|
||||
const hashUri = hashParams?.get('error_uri');
|
||||
|
||||
const error = queryError || hashError;
|
||||
if (error) {
|
||||
const desc = queryDesc || hashDesc;
|
||||
const uri = queryUri || hashUri;
|
||||
let message = `Failed to authorize: ${error}`;
|
||||
if (desc) message += ` (${desc})`;
|
||||
if (uri) message += ` [${uri}]`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const queryCode = query.get('code');
|
||||
if (queryCode) return queryCode;
|
||||
|
||||
const hashCode = hashParams?.get('code');
|
||||
if (hashCode) return hashCode;
|
||||
|
||||
console.log('[oauth2] Code not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolean {
|
||||
if (!redirectUrl) return true;
|
||||
|
||||
let redirect;
|
||||
try {
|
||||
redirect = new URL(redirectUrl);
|
||||
} catch {
|
||||
console.log('[oauth2] Invalid redirect URI; skipping.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const sameProtocol = url.protocol === redirect.protocol;
|
||||
|
||||
const sameHost = url.hostname.toLowerCase() === redirect.hostname.toLowerCase();
|
||||
|
||||
const normalizePort = (u: URL) =>
|
||||
(u.protocol === 'https:' && (!u.port || u.port === '443')) ||
|
||||
(u.protocol === 'http:' && (!u.port || u.port === '80'))
|
||||
? ''
|
||||
: u.port;
|
||||
|
||||
const samePort = normalizePort(url) === normalizePort(redirect);
|
||||
|
||||
const normPath = (p: string) => {
|
||||
const withLeading = p.startsWith('/') ? p : `/${p}`;
|
||||
// strip trailing slashes, keep root as "/"
|
||||
return withLeading.replace(/\/+$/g, '') || '/';
|
||||
};
|
||||
|
||||
// Require redirect path to be a prefix of the navigated URL path
|
||||
const urlPath = normPath(url.pathname);
|
||||
const redirectPath = normPath(redirect.pathname);
|
||||
const pathMatches = urlPath === redirectPath || urlPath.startsWith(`${redirectPath}/`);
|
||||
|
||||
return sameProtocol && sameHost && samePort && pathMatches;
|
||||
}
|
||||
|
||||
109
plugins/auth-oauth2/tests/util.test.ts
Normal file
109
plugins/auth-oauth2/tests/util.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { extractCode } from '../src/util';
|
||||
|
||||
describe('extractCode', () => {
|
||||
test('extracts code from query when same origin + path', () => {
|
||||
const url = 'https://app.example.com/cb?code=abc123&state=xyz';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc123');
|
||||
});
|
||||
|
||||
test('extracts code from query with weird path', () => {
|
||||
const url = 'https://app.example.com/cbwithextra?code=abc123&state=xyz';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBeNull();
|
||||
});
|
||||
|
||||
test('allows trailing slash differences', () => {
|
||||
expect(extractCode('https://app.example.com/cb/?code=abc', 'https://app.example.com/cb')).toBe(
|
||||
'abc',
|
||||
);
|
||||
expect(extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com/cb/')).toBe(
|
||||
'abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('treats default ports as equal (https:443, http:80)', () => {
|
||||
expect(
|
||||
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:443/cb'),
|
||||
).toBe('abc');
|
||||
expect(extractCode('http://app.example.com/cb?code=abc', 'http://app.example.com:80/cb')).toBe(
|
||||
'abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects different port', () => {
|
||||
expect(
|
||||
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:8443/cb'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects different hostname (including subdomain changes)', () => {
|
||||
expect(
|
||||
extractCode('https://evil.example.com/cb?code=abc', 'https://app.example.com/cb'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('requires path to start with redirect path (ignoring query/hash)', () => {
|
||||
// same origin but wrong path -> null
|
||||
expect(
|
||||
extractCode('https://app.example.com/other?code=abc', 'https://app.example.com/cb'),
|
||||
).toBeNull();
|
||||
|
||||
// deeper subpath under the redirect path -> allowed (prefix match)
|
||||
expect(
|
||||
extractCode('https://app.example.com/cb/deep?code=abc', 'https://app.example.com/cb'),
|
||||
).toBe('abc');
|
||||
});
|
||||
|
||||
test('works with custom schemes', () => {
|
||||
expect(extractCode('myapp://cb?code=abc', 'myapp://cb')).toBe('abc');
|
||||
});
|
||||
|
||||
test('prefers query over fragment when both present', () => {
|
||||
const url = 'https://app.example.com/cb?code=queryCode#code=hashCode';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('queryCode');
|
||||
});
|
||||
|
||||
test('extracts code from fragment when query lacks code', () => {
|
||||
const url = 'https://app.example.com/cb#code=fromHash&state=xyz';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('fromHash');
|
||||
});
|
||||
|
||||
test('returns null if no code present (query or fragment)', () => {
|
||||
const url = 'https://app.example.com/cb?state=only';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when provider reports an error', () => {
|
||||
const url = 'https://app.example.com/cb?error=access_denied&error_description=oopsy';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(() => extractCode(url, redirect)).toThrow('Failed to authorize: access_denied');
|
||||
});
|
||||
|
||||
test('when redirectUri is null, extracts code from any URL', () => {
|
||||
expect(extractCode('https://random.example.com/whatever?code=abc', null)).toBe('abc');
|
||||
});
|
||||
|
||||
test('handles extra params gracefully', () => {
|
||||
const url = 'https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc');
|
||||
});
|
||||
|
||||
test('ignores fragment noise when code is in query', () => {
|
||||
const url = 'https://app.example.com/cb?code=abc#some=thing';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc');
|
||||
});
|
||||
|
||||
// If you decide NOT to support fragment-based codes, flip these to expect null or mark as .skip
|
||||
test('supports fragment-only code for response_mode=fragment providers', () => {
|
||||
const url = 'https://app.example.com/cb#state=xyz&code=abc';
|
||||
const redirect = 'https://app.example.com/cb';
|
||||
expect(extractCode(url, redirect)).toBe('abc');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,39 @@
|
||||
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const options = [
|
||||
{ label: 'ASCII', value: 'ascii' },
|
||||
{ label: 'UTF-8', value: 'utf8' },
|
||||
{ label: 'UTF-16 LE', value: 'utf16le' },
|
||||
{ label: 'Base64', value: 'base64' },
|
||||
{ label: 'Base64 URL-safe', value: 'base64url' },
|
||||
{ label: 'Latin-1', value: 'latin1' },
|
||||
{ label: 'Hexadecimal', value: 'hex' },
|
||||
];
|
||||
export const plugin: PluginDefinition = {
|
||||
templateFunctions: [
|
||||
{
|
||||
name: 'fs.readFile',
|
||||
description: 'Read the contents of a file as utf-8',
|
||||
args: [{ title: 'Select File', type: 'file', name: 'path', label: 'File' }],
|
||||
args: [
|
||||
{ title: 'Select File', type: 'file', name: 'path', label: 'File' },
|
||||
{
|
||||
title: 'Select encoding',
|
||||
type: 'select',
|
||||
name: 'encoding',
|
||||
label: 'Encoding',
|
||||
defaultValue: 'utf8',
|
||||
description: 'Specifies how the file’s bytes are decoded into text when read',
|
||||
options,
|
||||
},
|
||||
],
|
||||
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
|
||||
if (!args.values.path) return null;
|
||||
if (!args.values.path || !args.values.encoding) return null;
|
||||
|
||||
try {
|
||||
return fs.promises.readFile(String(args.values.path ?? ''), 'utf-8');
|
||||
return fs.promises.readFile(String(args.values.path ?? ''), {
|
||||
encoding: String(args.values.encoding ?? 'utf-8') as BufferEncoding,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties} from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const DropMarker = memo(
|
||||
function DropMarker({ className }: Props) {
|
||||
function DropMarker({ className, style }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
'relative w-full h-0 overflow-visible pointer-events-none',
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
|
||||
@@ -71,7 +71,13 @@ function getItemKey(item: Model) {
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) {
|
||||
const SidebarLeftSlot = memo(function SidebarLeftSlot({
|
||||
treeId,
|
||||
item,
|
||||
}: {
|
||||
treeId: string;
|
||||
item: Model;
|
||||
}) {
|
||||
if (item.model === 'folder') {
|
||||
return <Icon icon="folder" />;
|
||||
} else if (item.model === 'workspace') {
|
||||
@@ -86,9 +92,9 @@ function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
|
||||
const SidebarInnerItem = memo(function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
|
||||
const response = useAtomValue(
|
||||
useMemo(
|
||||
() =>
|
||||
@@ -119,7 +125,7 @@ function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function NewSidebar({ className }: { className?: string }) {
|
||||
const [hidden, setHidden] = useSidebarHidden();
|
||||
@@ -292,7 +298,7 @@ const sidebarTreeAtom = atom((get) => {
|
||||
}
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<Model>): TreeNode<Model> => {
|
||||
const next = (node: TreeNode<Model>, depth: number): TreeNode<Model> => {
|
||||
const childItems = childrenMap[node.item.id] ?? [];
|
||||
|
||||
// Recurse to children
|
||||
@@ -301,18 +307,22 @@ const sidebarTreeAtom = atom((get) => {
|
||||
node.children = node.children ?? [];
|
||||
for (const item of childItems) {
|
||||
treeParentMap[item.id] = node;
|
||||
node.children.push(next({ item, parent: node }));
|
||||
node.children.push(next({ item, parent: node, depth }, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return next({
|
||||
item: activeWorkspace,
|
||||
children: [],
|
||||
parent: null,
|
||||
});
|
||||
return next(
|
||||
{
|
||||
item: activeWorkspace,
|
||||
children: [],
|
||||
parent: null,
|
||||
depth: 0,
|
||||
},
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
const actions = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-intern
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
request: HttpRequest | GrpcRequest | WebsocketRequest;
|
||||
@@ -23,7 +24,7 @@ const methodNames: Record<string, string> = {
|
||||
websocket: 'WS',
|
||||
};
|
||||
|
||||
export function HttpMethodTag({ request, className, short }: Props) {
|
||||
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const method =
|
||||
request.model === 'http_request' && request.bodyType === 'graphql'
|
||||
@@ -42,9 +43,9 @@ export function HttpMethodTag({ request, className, short }: Props) {
|
||||
short={short}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function HttpMethodTagRaw({
|
||||
function HttpMethodTagRaw({
|
||||
className,
|
||||
method,
|
||||
colored,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import * as lucide from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type { CSSProperties, HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const icons = {
|
||||
@@ -127,6 +127,7 @@ const icons = {
|
||||
export interface IconProps {
|
||||
icon: keyof typeof icons;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
spin?: boolean;
|
||||
title?: string;
|
||||
@@ -138,12 +139,14 @@ export const Icon = memo(function Icon({
|
||||
color = 'default',
|
||||
spin,
|
||||
size = 'md',
|
||||
style,
|
||||
className,
|
||||
title,
|
||||
}: IconProps) {
|
||||
const Component = icons[icon] ?? icons._unknown;
|
||||
return (
|
||||
<Component
|
||||
style={style}
|
||||
title={title}
|
||||
className={classNames(
|
||||
className,
|
||||
|
||||
@@ -9,31 +9,27 @@ import {
|
||||
} from '@dnd-kit/core';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
|
||||
import { useHotKey } from '../../../hooks/useHotKey';
|
||||
import { sidebarCollapsedAtom } from '../../../hooks/useSidebarItemCollapsed';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import type { ContextMenuProps } from '../Dropdown';
|
||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
|
||||
import {
|
||||
collapsedFamily,
|
||||
draggingIdsFamily,
|
||||
focusIdsFamily,
|
||||
hoveredParentFamily,
|
||||
selectedIdsFamily,
|
||||
} from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
import { useSelectableItems } from './useSelectableItems';
|
||||
|
||||
export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
@@ -75,8 +71,7 @@ function TreeInner<T extends { id: string }>(
|
||||
ref: Ref<TreeHandle>,
|
||||
) {
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
const selectableItems = useSelectableItems(root);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
||||
@@ -228,7 +223,12 @@ function TreeInner<T extends { id: string }>(
|
||||
const over = e.over;
|
||||
if (!over) {
|
||||
// Clear the drop indicator when hovering outside the tree
|
||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
childIndex: null,
|
||||
index: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -242,39 +242,59 @@ function TreeInner<T extends { id: string }>(
|
||||
if (hoveringRoot) {
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: root.item.id,
|
||||
index: root.children?.length ?? 0,
|
||||
parentDepth: root.depth,
|
||||
index: selectableItems.length,
|
||||
childIndex: selectableItems.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null;
|
||||
if (node == null) {
|
||||
const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
|
||||
if (selectableItem == null) {
|
||||
return;
|
||||
}
|
||||
const node = selectableItem.node;
|
||||
|
||||
const side = computeSideForDragMove(node, e);
|
||||
|
||||
const item = node.item;
|
||||
let hoveredParent = treeParentMap[item.id] ?? null;
|
||||
const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99;
|
||||
const hovered = hoveredParent?.children?.[dragIndex] ?? null;
|
||||
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
||||
let hoveredParent = node.parent;
|
||||
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
|
||||
const hovered = selectableItems[dragIndex]?.node ?? null;
|
||||
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
||||
let hoveredChildIndex = selectableItem.index + (side === 'above' ? 0 : 1);
|
||||
|
||||
const collapsedMap = jotaiStore.get(jotaiStore.get(sidebarCollapsedAtom));
|
||||
const collapsedMap = jotaiStore.get(collapsedFamily(treeId));
|
||||
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
|
||||
|
||||
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
|
||||
// Move into the folder if it's open and we're moving below it
|
||||
hoveredParent = hoveredParent?.children?.find((n) => n.item.id === item.id) ?? null;
|
||||
hoveredIndex = 0;
|
||||
hoveredParent = hovered;
|
||||
hoveredChildIndex = 0;
|
||||
}
|
||||
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: hoveredParent?.item.id ?? null,
|
||||
index: hoveredIndex,
|
||||
});
|
||||
const parentId = hoveredParent?.item.id ?? null;
|
||||
const parentDepth = hoveredParent?.depth ?? null;
|
||||
const index = hoveredIndex;
|
||||
const childIndex = hoveredChildIndex;
|
||||
const existing = jotaiStore.get(hoveredParentFamily(treeId));
|
||||
if (
|
||||
!(
|
||||
parentId === existing.parentId &&
|
||||
parentDepth === existing.parentDepth &&
|
||||
index === existing.index &&
|
||||
childIndex === existing.childIndex
|
||||
)
|
||||
) {
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: hoveredParent?.item.id ?? null,
|
||||
parentDepth: hoveredParent?.depth ?? null,
|
||||
index: hoveredIndex,
|
||||
childIndex: hoveredChildIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[root.children?.length, root.item.id, selectableItems, treeId, treeParentMap],
|
||||
[root.depth, root.item.id, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
@@ -299,46 +319,57 @@ function TreeInner<T extends { id: string }>(
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
index: null,
|
||||
childIndex: null,
|
||||
});
|
||||
jotaiStore.set(draggingIdsFamily(treeId), []);
|
||||
}, [treeId]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
function handleDragEnd(e: DragEndEvent) {
|
||||
// Get this from the store so our callback doesn't change all the time
|
||||
const hovered = jotaiStore.get(hoveredParentFamily(treeId));
|
||||
const {
|
||||
index: hoveredIndex,
|
||||
parentId: hoveredParentId,
|
||||
childIndex: hoveredChildIndex,
|
||||
} = jotaiStore.get(hoveredParentFamily(treeId));
|
||||
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
|
||||
clearDragState();
|
||||
|
||||
// Dropped outside the tree?
|
||||
if (e.over == null) return;
|
||||
if (e.over == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredParent =
|
||||
hovered.parentId == root.item.id
|
||||
? root
|
||||
: selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node;
|
||||
const hoveredParentS =
|
||||
hoveredParentId === root.item.id
|
||||
? { node: root, depth: 0, index: 0 }
|
||||
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
|
||||
const hoveredParent = hoveredParentS?.node ?? null;
|
||||
|
||||
if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return;
|
||||
|
||||
// Optional tiny guard: don't drop into itself
|
||||
if (draggingItems.some((id) => id === hovered.parentId)) return;
|
||||
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
|
||||
const draggedNodes: TreeNode<T>[] = draggingItems
|
||||
.map((id) => {
|
||||
const parent = treeParentMap[id];
|
||||
const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1;
|
||||
return idx >= 0 ? parent!.children![idx]! : null;
|
||||
return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
|
||||
})
|
||||
.filter((n) => n != null)
|
||||
// Filter out invalid drags (dragging into descendant)
|
||||
.filter((n) => !hasAncestor(hoveredParent, n.item.id));
|
||||
.filter(
|
||||
(n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),
|
||||
);
|
||||
|
||||
// Work on a local copy of target children
|
||||
const nextChildren = [...(hoveredParent.children ?? [])];
|
||||
|
||||
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
||||
let insertAt = hovered.index;
|
||||
let insertAt = hoveredChildIndex ?? 0;
|
||||
for (const node of draggedNodes) {
|
||||
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
||||
if (i !== -1) {
|
||||
@@ -355,14 +386,13 @@ function TreeInner<T extends { id: string }>(
|
||||
insertAt,
|
||||
});
|
||||
},
|
||||
[treeId, clearDragState, root, selectableItems, onDragEnd, treeParentMap],
|
||||
[treeId, clearDragState, selectableItems, root, onDragEnd],
|
||||
);
|
||||
|
||||
const treeItemListProps: Omit<
|
||||
TreeItemListProps<T>,
|
||||
'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||
'nodes' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||
> = {
|
||||
depth: 0,
|
||||
getItemKey,
|
||||
getContextMenu: handleGetContextMenu,
|
||||
onClick: handleClick,
|
||||
@@ -371,14 +401,6 @@ function TreeInner<T extends { id: string }>(
|
||||
ItemLeftSlot,
|
||||
};
|
||||
|
||||
const handleFocus = useCallback(function handleFocus() {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(function handleBlur() {
|
||||
setIsFocused(false);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
@@ -396,30 +418,37 @@ function TreeInner<T extends { id: string }>(
|
||||
>
|
||||
<div
|
||||
ref={treeRef}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={classNames(
|
||||
className,
|
||||
'outline-none h-full',
|
||||
'overflow-y-auto overflow-x-hidden',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
' [&_.tree-item.selected]:text-text',
|
||||
isFocused
|
||||
? '[&_.tree-item.selected]:bg-surface-active'
|
||||
: '[&_.tree-item.selected]:bg-surface-highlight',
|
||||
)}
|
||||
>
|
||||
<TreeItemList node={root} treeId={treeId} {...treeItemListProps} />
|
||||
<div
|
||||
className={classNames(
|
||||
'[&_.tree-item-inner]:bg-surface',
|
||||
'[&_.tree-item-selectable.selected]:text-text',
|
||||
'[&:focus-within]:[&_.tree-item.selected]:bg-surface-active',
|
||||
'[&:not(:focus-within)]:[&_.tree-item.selected]:bg-surface-highlight',
|
||||
|
||||
// Round the items, but only if the ends of the selection
|
||||
'[&_.tree-item]:rounded-md',
|
||||
'[&_.tree-item.selected+.tree-item.selected]:rounded-t-none',
|
||||
'[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none',
|
||||
)}
|
||||
>
|
||||
<TreeItemList nodes={selectableItems} treeId={treeId} {...treeItemListProps} />
|
||||
</div>
|
||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||
<DropRegionAfterList id={root.item.id} />
|
||||
<TreeDragOverlay
|
||||
treeId={treeId}
|
||||
root={root}
|
||||
selectableItems={selectableItems}
|
||||
ItemInner={ItemInner}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</div>
|
||||
<TreeDragOverlay
|
||||
treeId={treeId}
|
||||
selectableItems={selectableItems}
|
||||
ItemInner={ItemInner}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
@@ -447,63 +476,6 @@ function DropRegionAfterList({ id }: { id: string }) {
|
||||
return <div ref={setNodeRef} />;
|
||||
}
|
||||
|
||||
function useTreeParentMap<T extends { id: string }>(
|
||||
root: TreeNode<T>,
|
||||
getItemKey: (item: T) => string,
|
||||
) {
|
||||
const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
||||
const [{ treeParentMap, selectableItems }, setData] = useState(() => {
|
||||
return compute(root, collapsedMap);
|
||||
});
|
||||
|
||||
const prevRoot = useRef<TreeNode<T> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldRecompute =
|
||||
root == null || prevRoot.current == null || !equalSubtree(root, prevRoot.current, getItemKey);
|
||||
if (!shouldRecompute) return;
|
||||
setData(compute(root, collapsedMap));
|
||||
prevRoot.current = root;
|
||||
}, [collapsedMap, getItemKey, root]);
|
||||
|
||||
return { treeParentMap, selectableItems };
|
||||
}
|
||||
|
||||
function compute<T extends { id: string }>(
|
||||
root: TreeNode<T>,
|
||||
collapsedMap: Record<string, boolean>,
|
||||
) {
|
||||
const treeParentMap: Record<string, TreeNode<T>> = {};
|
||||
const selectableItems: SelectableTreeNode<T>[] = [];
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<T>, depth: number = 0) => {
|
||||
const isCollapsed = collapsedMap[node.item.id] === true;
|
||||
// console.log("IS COLLAPSED", node.item.name, isCollapsed);
|
||||
if (node.children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse to children
|
||||
let selectableIndex = 0;
|
||||
for (const child of node.children) {
|
||||
treeParentMap[child.item.id] = node;
|
||||
if (!isCollapsed) {
|
||||
selectableItems.push({
|
||||
node: child,
|
||||
index: selectableIndex++,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
|
||||
next(child, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
next(root);
|
||||
return { treeParentMap, selectableItems };
|
||||
}
|
||||
|
||||
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
||||
action: HotkeyAction;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { DragOverlay } from '@dnd-kit/core';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { draggingIdsFamily } from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
|
||||
export function TreeDragOverlay<T extends { id: string }>({
|
||||
treeId,
|
||||
root,
|
||||
selectableItems,
|
||||
getItemKey,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
}: {
|
||||
treeId: string;
|
||||
root: TreeNode<T>;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
|
||||
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||
@@ -22,22 +20,11 @@ export function TreeDragOverlay<T extends { id: string }>({
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<TreeItemList
|
||||
treeId={treeId + '.dragging'}
|
||||
node={{
|
||||
item: { ...root.item, id: `${root.item.id}_dragging` },
|
||||
parent: null,
|
||||
children: draggingItems
|
||||
.map((id) => {
|
||||
const child = selectableItems.find((i2) => {
|
||||
return i2.node.item.id === id;
|
||||
})?.node;
|
||||
return child == null ? null : { ...child, children: undefined };
|
||||
})
|
||||
.filter((c) => c != null),
|
||||
}}
|
||||
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
depth={0}
|
||||
forceDepth={0}
|
||||
/>
|
||||
</DragOverlay>
|
||||
);
|
||||
|
||||
34
src-web/components/core/tree/TreeDropMarker.tsx
Normal file
34
src-web/components/core/tree/TreeDropMarker.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
import { DropMarker } from '../../DropMarker';
|
||||
import { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from './atoms';
|
||||
|
||||
export const TreeDropMarker = memo(function TreeDropMarker({
|
||||
className,
|
||||
treeId,
|
||||
itemId,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
index: number;
|
||||
itemId: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: itemId ?? undefined }));
|
||||
|
||||
// Only show if we're hovering over this index
|
||||
if (!isHovered) return null;
|
||||
|
||||
// Don't show if we're right under a collapsed folder. We have a separate delayed expansion
|
||||
// animation for that.
|
||||
if (collapsed) return null;
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: `${parentDepth}rem` }}>
|
||||
<DropMarker className={classNames(className)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
28
src-web/components/core/tree/TreeIndentGuide.tsx
Normal file
28
src-web/components/core/tree/TreeIndentGuide.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo } from 'react';
|
||||
import { hoveredParentDepthFamily } from './atoms';
|
||||
|
||||
export const TreeIndentGuide = memo(function TreeIndentGuide({
|
||||
treeId,
|
||||
depth,
|
||||
}: {
|
||||
treeId: string;
|
||||
depth: number;
|
||||
}) {
|
||||
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{Array.from({ length: depth }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={classNames(
|
||||
'w-[1rem] border-r border-r-text-subtlest',
|
||||
parentDepth !== i + 1 && 'opacity-30',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2,21 +2,18 @@ import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import type { MouseEvent, PointerEvent } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||
import { ContextMenu } from '../Dropdown';
|
||||
import { Icon } from '../Icon';
|
||||
import {
|
||||
isCollapsedFamily,
|
||||
isLastFocusedFamily,
|
||||
isParentHoveredFamily,
|
||||
isSelectedFamily,
|
||||
} from './atoms';
|
||||
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { computeSideForDragMove } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeIndentGuide } from './TreeIndentGuide';
|
||||
|
||||
interface OnClickEvent {
|
||||
shiftKey: boolean;
|
||||
@@ -26,17 +23,18 @@ interface OnClickEvent {
|
||||
|
||||
export type TreeItemProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions'
|
||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
|
||||
> & {
|
||||
node: TreeNode<T>;
|
||||
className?: string;
|
||||
onClick?: (item: T, e: OnClickEvent) => void;
|
||||
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||
|
||||
export function TreeItem<T extends { id: string }>({
|
||||
function TreeItem_<T extends { id: string }>({
|
||||
treeId,
|
||||
node,
|
||||
ItemInner,
|
||||
@@ -45,17 +43,34 @@ export function TreeItem<T extends { id: string }>({
|
||||
onClick,
|
||||
getEditOptions,
|
||||
className,
|
||||
depth,
|
||||
}: TreeItemProps<T>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
const isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [isDropHover, setIsDropHover] = useState<boolean>(false);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
const isAncestorCollapsedAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
collapsedFamily(treeId),
|
||||
(collapsed) => {
|
||||
const next = (n: TreeNode<T>) => {
|
||||
if (n.parent == null) return false;
|
||||
if (collapsed[n.parent.item.id]) return true;
|
||||
return next(n.parent);
|
||||
};
|
||||
return next(node);
|
||||
},
|
||||
(a, b) => a === b, // re-render only when boolean flips
|
||||
),
|
||||
[node, treeId],
|
||||
);
|
||||
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: DropdownItem[];
|
||||
x: number;
|
||||
@@ -160,7 +175,7 @@ export function TreeItem<T extends { id: string }>({
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: MouseEvent<HTMLDivElement>) => {
|
||||
async (e: MouseEvent<HTMLElement>) => {
|
||||
if (getContextMenu == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
@@ -197,77 +212,107 @@ export function TreeItem<T extends { id: string }>({
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
if (useAtomValue(isAncestorCollapsedAtom)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
<li
|
||||
ref={ref}
|
||||
role="treeitem"
|
||||
aria-level={depth + 1}
|
||||
aria-expanded={node.children == null ? undefined : !isCollapsed}
|
||||
aria-selected={isSelected}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={classNames(
|
||||
className,
|
||||
'tree-item',
|
||||
'h-sm',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)]',
|
||||
isSelected && 'selected',
|
||||
'text-text-subtle',
|
||||
'h-sm grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md px-1.5',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
|
||||
)}
|
||||
>
|
||||
{showContextMenu && (
|
||||
<ContextMenu
|
||||
items={showContextMenu.items}
|
||||
triggerPosition={showContextMenu}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
)}
|
||||
{node.children != null ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="h-full w-[2.8rem] pr-[0.5rem] -ml-[1rem]"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className={classNames(
|
||||
'transition-transform text-text-subtlest',
|
||||
'ml-auto !h-[1rem] !w-[1rem]',
|
||||
node.children.length == 0 && 'opacity-0',
|
||||
!isCollapsed && 'rotate-90',
|
||||
isHoveredAsParent && '!text-text',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
ref={handleSetDraggableRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
disabled={editing}
|
||||
className="focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={isLastSelected ? 0 : -1}
|
||||
>
|
||||
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
|
||||
{getEditOptions != null && editing ? (
|
||||
(() => {
|
||||
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||
return (
|
||||
<input
|
||||
ref={handleEditFocus}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className="bg-transparent outline-none w-full cursor-text"
|
||||
onBlur={handleEditBlur}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<ItemInner treeId={treeId} item={node.item} />
|
||||
<TreeIndentGuide treeId={treeId} depth={depth} />
|
||||
<div
|
||||
className={classNames(
|
||||
'tree-item-selectable',
|
||||
'text-text-subtle',
|
||||
isSelected && 'selected',
|
||||
'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
>
|
||||
{showContextMenu && (
|
||||
<ContextMenu
|
||||
items={showContextMenu.items}
|
||||
triggerPosition={showContextMenu}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
)}
|
||||
{node.children != null ? (
|
||||
<button tabIndex={-1} className="h-full pl-[0.5rem]" onClick={toggleCollapsed}>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className={classNames(
|
||||
'transition-transform text-text-subtlest',
|
||||
'ml-auto',
|
||||
'w-[1rem] h-[1rem]',
|
||||
// node.children.length == 0 && 'opacity-0',
|
||||
!isCollapsed && 'rotate-90',
|
||||
// isHoveredAsParent && '!text-text',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden /> // Make the grid happy
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={handleSetDraggableRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
disabled={editing}
|
||||
className="px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={isLastSelected ? 0 : -1}
|
||||
>
|
||||
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
|
||||
{getEditOptions != null && editing ? (
|
||||
(() => {
|
||||
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||
return (
|
||||
<input
|
||||
ref={handleEditFocus}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className="bg-transparent outline-none w-full cursor-text"
|
||||
onBlur={handleEditBlur}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<ItemInner treeId={treeId} item={node.item} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const TreeItem = memo(
|
||||
TreeItem_,
|
||||
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
||||
const nonEqualKeys = [];
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
nonEqualKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (nonEqualKeys.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item);
|
||||
},
|
||||
) as typeof TreeItem_;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { DropMarker } from '../../DropMarker';
|
||||
import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { equalSubtree } from './common';
|
||||
import type { SelectableTreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeDropMarker } from './TreeDropMarker';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import { TreeItem } from './TreeItem';
|
||||
|
||||
@@ -15,81 +11,54 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
|
||||
> &
|
||||
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
nodes: SelectableTreeNode<T>[];
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
forceDepth?: number;
|
||||
};
|
||||
|
||||
function TreeItemList_<T extends { id: string }>({
|
||||
className,
|
||||
depth,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
node,
|
||||
nodes,
|
||||
onClick,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
style,
|
||||
treeId,
|
||||
forceDepth,
|
||||
}: TreeItemListProps<T>) {
|
||||
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
const childList = !isCollapsed && node.children != null && (
|
||||
<ul
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
depth > 0 && 'ml-[calc(1.2rem+0.5px)] pl-[0.7rem] border-l',
|
||||
isHovered ? 'border-l-text-subtle' : 'border-l-border-subtle',
|
||||
)}
|
||||
>
|
||||
{node.children.map(function mapChild(child, i) {
|
||||
return (
|
||||
<Fragment key={getItemKey(child.item)}>
|
||||
<TreeDropMarker treeId={treeId} parent={node} index={i} />
|
||||
<TreeItemList
|
||||
treeId={treeId}
|
||||
node={child}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
onClick={onClick}
|
||||
getEditOptions={getEditOptions}
|
||||
depth={depth + 1}
|
||||
getItemKey={getItemKey}
|
||||
getContextMenu={getContextMenu}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<TreeDropMarker treeId={treeId} parent={node ?? null} index={node.children?.length ?? 0} />
|
||||
</ul>
|
||||
);
|
||||
|
||||
if (depth === 0) {
|
||||
return childList;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<TreeItem
|
||||
treeId={treeId}
|
||||
node={node}
|
||||
getContextMenu={getContextMenu}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
onClick={onClick}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
{childList}
|
||||
</li>
|
||||
<ul role="tree" style={style} className={className}>
|
||||
<TreeDropMarker itemId={null} treeId={treeId} index={0} />
|
||||
{nodes.map((child, i) => (
|
||||
<Fragment key={getItemKey(child.node.item)}>
|
||||
<TreeItem
|
||||
treeId={treeId}
|
||||
node={child.node}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
onClick={onClick}
|
||||
getEditOptions={getEditOptions}
|
||||
getContextMenu={getContextMenu}
|
||||
getItemKey={getItemKey}
|
||||
depth={forceDepth == null ? child.depth : forceDepth}
|
||||
/>
|
||||
<TreeDropMarker itemId={child.node.item.id} treeId={treeId} index={i+1} />
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export const TreeItemList = memo(
|
||||
TreeItemList_,
|
||||
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
||||
(
|
||||
{ nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps },
|
||||
{ nodes: nextNodes, getItemKey: nextGetItemKey, ...nextProps },
|
||||
) => {
|
||||
const nonEqualKeys = [];
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
@@ -100,32 +69,16 @@ export const TreeItemList = memo(
|
||||
// console.log('TreeItemList: ', nonEqualKeys);
|
||||
return false;
|
||||
}
|
||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||
if (prevNodes.length !== nextNodes.length) return false;
|
||||
|
||||
for (let i = 0; i < prevNodes.length; i++) {
|
||||
const prev = prevNodes[i]!;
|
||||
const next = nextNodes[i]!;
|
||||
if (prevGetItemKey(prev.node.item) !== nextGetItemKey(next.node.item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
) as typeof TreeItemList_;
|
||||
|
||||
const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
||||
className,
|
||||
treeId,
|
||||
parent,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
parent: TreeNode<T> | null;
|
||||
index: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const isHovered = useAtomValue(isItemHoveredFamily({ treeId, parentId: parent?.item.id, index }));
|
||||
const isLastItem = parent?.children?.length === index;
|
||||
const isLastItemHovered = useAtomValue(
|
||||
isItemHoveredFamily({
|
||||
treeId,
|
||||
parentId: parent?.item.id,
|
||||
index: parent?.children?.length ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isHovered && !(isLastItem && isLastItemHovered)) return null;
|
||||
|
||||
return <DropMarker className={classNames(className)} />;
|
||||
});
|
||||
|
||||
@@ -32,43 +32,40 @@ export const draggingIdsFamily = atomFamily((_treeId: string) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const hoveredParentFamily = atomFamily((_treeId: string) => {
|
||||
return atom<{ index: number | null; parentId: string | null }>({ index: null, parentId: null });
|
||||
return atom<{
|
||||
index: number | null;
|
||||
childIndex: number | null;
|
||||
parentId: string | null;
|
||||
parentDepth: number | null;
|
||||
}>({
|
||||
index: null,
|
||||
childIndex: null,
|
||||
parentId: null,
|
||||
parentDepth: null,
|
||||
});
|
||||
});
|
||||
|
||||
export const isParentHoveredFamily = atomFamily(
|
||||
({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) =>
|
||||
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
|
||||
export const isIndexHoveredFamily = atomFamily(
|
||||
({ treeId, index }: { treeId: string; index: number}) =>
|
||||
selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.index === b.index,
|
||||
);
|
||||
|
||||
export const isItemHoveredFamily = atomFamily(
|
||||
({
|
||||
treeId,
|
||||
parentId,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
parentId: string | null | undefined;
|
||||
index: number | null;
|
||||
}) =>
|
||||
selectAtom(
|
||||
hoveredParentFamily(treeId),
|
||||
(v) => v.parentId === parentId && v.index === index,
|
||||
Object.is,
|
||||
),
|
||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId && a.index === b.index,
|
||||
export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
|
||||
selectAtom(
|
||||
hoveredParentFamily(treeId),
|
||||
(s) => s.parentDepth,
|
||||
(a, b) => Object.is(a, b) // prevents re-render unless the value changes
|
||||
)
|
||||
);
|
||||
|
||||
function kvKey(workspaceId: string | null) {
|
||||
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||
}
|
||||
|
||||
export const collapsedFamily = atomFamily((workspaceId: string) => {
|
||||
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||
const key = ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||
return atomWithKVStorage<Record<string, boolean>>(key, {});
|
||||
});
|
||||
|
||||
export const isCollapsedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||
({ treeId, itemId = 'n/a' }: { treeId: string; itemId: string | undefined }) =>
|
||||
atom(
|
||||
// --- getter ---
|
||||
(get) => !!get(collapsedFamily(treeId))[itemId],
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { selectedIdsFamily } from './atoms';
|
||||
|
||||
export interface TreeNode<T extends { id: string }> {
|
||||
export interface TreeNode<T extends { id: string } > {
|
||||
children?: TreeNode<T>[];
|
||||
item: T;
|
||||
parent: TreeNode<T> | null;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface SelectableTreeNode<T extends { id: string }> {
|
||||
@@ -41,9 +42,10 @@ export function equalSubtree<T extends { id: string }>(
|
||||
}
|
||||
|
||||
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||
// Check parents recursively
|
||||
if (node.parent == null) return false;
|
||||
if (node.parent.item.id === ancestorId) return true;
|
||||
|
||||
// Check parents recursively
|
||||
return hasAncestor(node.parent, ancestorId);
|
||||
}
|
||||
|
||||
|
||||
30
src-web/components/core/tree/useSelectableItems.ts
Normal file
30
src-web/components/core/tree/useSelectableItems.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
|
||||
export function useSelectableItems<T extends { id: string }>(root: TreeNode<T>) {
|
||||
return useMemo(() => {
|
||||
const selectableItems: SelectableTreeNode<T>[] = [];
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<T>, depth: number = 0) => {
|
||||
if (node.children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse to children
|
||||
let selectableIndex = 0;
|
||||
for (const child of node.children) {
|
||||
selectableItems.push({
|
||||
node: child,
|
||||
index: selectableIndex++,
|
||||
depth,
|
||||
});
|
||||
|
||||
next(child, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
next(root);
|
||||
return selectableItems;
|
||||
}, [root]);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ function GraphQLExplorerHeader({
|
||||
};
|
||||
const crumbs = findIt(item);
|
||||
return (
|
||||
<nav className="pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,1fr)_auto] items-center min-w-0 gap-1">
|
||||
<nav className="pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,1fr)_auto] items-center min-w-0 gap-1 z-10">
|
||||
<div className="@container w-full relative pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,min-content)_auto] items-center gap-1">
|
||||
<div className="whitespace-nowrap flex items-center gap-2 text-text-subtle text-sm overflow-x-auto hide-scrollbars">
|
||||
<Icon icon="book_open_text" />
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||
|
||||
function kvKey(workspaceId: string | null) {
|
||||
@@ -12,18 +10,3 @@ export const sidebarCollapsedAtom = atom((get) => {
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||
});
|
||||
|
||||
export function useSidebarItemCollapsed(itemId: string) {
|
||||
const map = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
||||
const isCollapsed = map[itemId] === true;
|
||||
|
||||
const toggle = useCallback(() => toggleSidebarItemCollapsed(itemId), [itemId]);
|
||||
|
||||
return [isCollapsed, toggle] as const;
|
||||
}
|
||||
|
||||
export function toggleSidebarItemCollapsed(itemId: string) {
|
||||
jotaiStore.set(jotaiStore.get(sidebarCollapsedAtom), (prev) => {
|
||||
return { ...prev, [itemId]: !prev[itemId] };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user