mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-21 17:09:37 +01:00
Tree fixes and sidebar filter DSL
This commit is contained in:
@@ -21,6 +21,7 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onC
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
hotkeyAction?: HotkeyAction;
|
||||
hotkeyLabelOnly?: boolean;
|
||||
hotkeyPriority?: number;
|
||||
};
|
||||
|
||||
@@ -41,6 +42,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
disabled,
|
||||
hotkeyAction,
|
||||
hotkeyPriority,
|
||||
hotkeyLabelOnly,
|
||||
title,
|
||||
onClick,
|
||||
...props
|
||||
@@ -65,7 +67,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
'hocus:opacity-100', // Force opacity for certain hover effects
|
||||
'whitespace-nowrap outline-none',
|
||||
'flex-shrink-0 flex items-center',
|
||||
'focus-visible-or-class:ring',
|
||||
'outline-0',
|
||||
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
|
||||
justify === 'start' && 'justify-start',
|
||||
justify === 'center' && 'justify-center',
|
||||
@@ -76,10 +78,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
|
||||
// Solids
|
||||
variant === 'solid' && 'border-transparent',
|
||||
variant === 'solid' && color === 'custom' && 'ring-border-focus',
|
||||
variant === 'solid' && color === 'custom' && 'outline-border-focus',
|
||||
variant === 'solid' &&
|
||||
color !== 'custom' &&
|
||||
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight ring-border-subtle',
|
||||
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',
|
||||
variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface',
|
||||
|
||||
// Borders
|
||||
@@ -87,7 +89,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
variant === 'border' &&
|
||||
color !== 'custom' &&
|
||||
'border-border-subtle text-text-subtle enabled:hocus:border-border ' +
|
||||
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text ring-border-subtler',
|
||||
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler',
|
||||
);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -101,7 +103,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
() => {
|
||||
buttonRef.current?.click();
|
||||
},
|
||||
{ priority: hotkeyPriority },
|
||||
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,12 +17,12 @@ import { useAtomValue } from 'jotai';
|
||||
import { md5 } from 'js-md5';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -75,14 +75,14 @@ export interface EditorProps {
|
||||
defaultValue?: string | null;
|
||||
disableTabIndent?: boolean;
|
||||
disabled?: boolean;
|
||||
extraExtensions?: Extension[];
|
||||
extraExtensions?: Extension[] | Extension;
|
||||
forcedEnvironmentId?: string;
|
||||
forceUpdateKey?: string | number;
|
||||
format?: (v: string) => Promise<string>;
|
||||
heightMode?: 'auto' | 'full';
|
||||
hideGutter?: boolean;
|
||||
id?: string;
|
||||
language?: EditorLanguage | 'pairs' | 'url';
|
||||
language?: EditorLanguage | 'pairs' | 'url' | null;
|
||||
graphQLSchema?: GraphQLSchema | null;
|
||||
onBlur?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -439,7 +439,11 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
}),
|
||||
...(extraExtensions ?? []),
|
||||
...(Array.isArray(extraExtensions)
|
||||
? extraExtensions
|
||||
: extraExtensions
|
||||
? [extraExtensions]
|
||||
: []),
|
||||
];
|
||||
|
||||
const cachedJsonState = getCachedEditorState(defaultValue ?? '', stateKey);
|
||||
@@ -470,9 +474,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[forceUpdateKey],
|
||||
[],
|
||||
);
|
||||
|
||||
// Update editor doc when force update key changes
|
||||
useEffect(() => {
|
||||
if (cm.current?.view != null) {
|
||||
updateContents(cm.current.view, defaultValue || '');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
// For read-only mode, update content when `defaultValue` changes
|
||||
useEffect(
|
||||
function updateReadOnlyEditor() {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import { bracketMatching ,
|
||||
import {
|
||||
bracketMatching,
|
||||
codeFolding,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
@@ -152,8 +153,11 @@ export function getLanguageExtension({
|
||||
];
|
||||
}
|
||||
|
||||
const base_ = syntaxExtensions[language ?? 'text'] ?? text();
|
||||
const base = typeof base_ === 'function' ? base_() : text();
|
||||
const maybeBase = language ? syntaxExtensions[language] : null;
|
||||
const base = typeof maybeBase === 'function' ? maybeBase() : null;
|
||||
if (base == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!useTemplating) {
|
||||
return [base, extraExtensions];
|
||||
|
||||
182
src-web/components/core/Editor/filter/extension.ts
Normal file
182
src-web/components/core/Editor/filter/extension.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { autocompletion, startCompletion } from '@codemirror/autocomplete';
|
||||
import { LanguageSupport, LRLanguage, syntaxTree } from '@codemirror/language';
|
||||
import { parser } from './filter';
|
||||
|
||||
export interface FieldDef {
|
||||
name: string;
|
||||
// Optional static or dynamic value suggestions for this field
|
||||
values?: string[] | (() => string[]);
|
||||
info?: string;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
||||
}
|
||||
|
||||
const IDENT = /[A-Za-z0-9_/]+$/;
|
||||
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
|
||||
|
||||
function normalizeFields(fields: FieldDef[]): {
|
||||
fieldNames: string[];
|
||||
fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }>;
|
||||
} {
|
||||
const fieldNames: string[] = [];
|
||||
const fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }> = {};
|
||||
for (const f of fields) {
|
||||
fieldNames.push(f.name);
|
||||
fieldMap[f.name] = { values: f.values, info: f.info };
|
||||
}
|
||||
return { fieldNames, fieldMap };
|
||||
}
|
||||
|
||||
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
|
||||
const upto = doc.slice(0, pos);
|
||||
const m = upto.match(IDENT);
|
||||
if (!m) return null;
|
||||
const from = pos - m[0].length;
|
||||
return { from, to: pos, text: m[0] };
|
||||
}
|
||||
|
||||
function inPhrase(ctx: CompletionContext): boolean {
|
||||
// Lezer node names from your grammar: Phrase is the quoted token
|
||||
let n = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
||||
for (; n; n = n.parent!) {
|
||||
if (n.name === 'Phrase') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// While typing an incomplete quote, there's no Phrase token yet.
|
||||
function inUnclosedQuote(doc: string, pos: number): boolean {
|
||||
let quotes = 0;
|
||||
for (let i = 0; i < pos; i++) {
|
||||
if (doc[i] === '"' && doc[i - 1] !== '\\') quotes++;
|
||||
}
|
||||
return quotes % 2 === 1; // odd = inside an open quote
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic context detector (works without relying on exact node names):
|
||||
* - If there's a ':' after the last whitespace and before the cursor, we're in a field value.
|
||||
* - Otherwise, we're in a field name or bare term position.
|
||||
*/
|
||||
function contextInfo(stateDoc: string, pos: number) {
|
||||
const lastColon = stateDoc.lastIndexOf(':', pos - 1);
|
||||
const lastBoundary = Math.max(
|
||||
stateDoc.lastIndexOf(' ', pos - 1),
|
||||
stateDoc.lastIndexOf('\t', pos - 1),
|
||||
stateDoc.lastIndexOf('\n', pos - 1),
|
||||
stateDoc.lastIndexOf('(', pos - 1),
|
||||
stateDoc.lastIndexOf(')', pos - 1),
|
||||
);
|
||||
|
||||
const inValue = lastColon > lastBoundary;
|
||||
|
||||
let fieldName: string | null = null;
|
||||
let emptyAfterColon = false;
|
||||
|
||||
if (inValue) {
|
||||
// word before the colon = field name
|
||||
const beforeColon = stateDoc.slice(0, lastColon);
|
||||
const m = beforeColon.match(IDENT);
|
||||
fieldName = m ? m[0] : null;
|
||||
|
||||
// nothing (or only spaces) typed after the colon?
|
||||
const after = stateDoc.slice(lastColon + 1, pos);
|
||||
emptyAfterColon = after.length === 0 || /^\s+$/.test(after);
|
||||
}
|
||||
|
||||
return { inValue, fieldName, lastColon, emptyAfterColon };
|
||||
}
|
||||
|
||||
/** Build a completion list for field names */
|
||||
function fieldNameCompletions(fieldNames: string[]): Completion[] {
|
||||
return fieldNames.map((name) => ({
|
||||
label: name,
|
||||
type: 'property',
|
||||
apply: (view, _completion, from, to) => {
|
||||
// Insert "name:" (leave cursor right after colon)
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `${name}:` },
|
||||
selection: { anchor: from + name.length + 1 },
|
||||
});
|
||||
startCompletion(view);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Build a completion list for field values (if provided) */
|
||||
function fieldValueCompletions(
|
||||
def: { values?: string[] | (() => string[]); info?: string } | undefined,
|
||||
): Completion[] | null {
|
||||
if (!def || !def.values) return null;
|
||||
const vals = Array.isArray(def.values) ? def.values : def.values();
|
||||
// console.log("HELLO", v, v.match(IDENT));
|
||||
return vals.map((v) => ({
|
||||
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
|
||||
displayLabel: v,
|
||||
type: 'constant',
|
||||
}));
|
||||
}
|
||||
|
||||
/** The main completion source */
|
||||
function makeCompletionSource(opts: FilterOptions) {
|
||||
const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
|
||||
return (ctx: CompletionContext): CompletionResult | null => {
|
||||
const { state, pos } = ctx;
|
||||
const doc = state.doc.toString();
|
||||
|
||||
if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const w = wordBefore(doc, pos);
|
||||
const from = w?.from ?? pos;
|
||||
const to = pos;
|
||||
|
||||
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||
|
||||
// In field value position
|
||||
if (inValue && fieldName) {
|
||||
const valDefs = fieldMap[fieldName];
|
||||
const vals = fieldValueCompletions(valDefs);
|
||||
|
||||
// If user hasn't typed a value char yet:
|
||||
// - Show value suggestions if available
|
||||
// - Otherwise show nothing (no fallback to field names)
|
||||
if (emptyAfterColon) {
|
||||
if (vals?.length) {
|
||||
return { from, to, options: vals, filter: true };
|
||||
}
|
||||
return null; // <-- key change: do not suggest fields here
|
||||
}
|
||||
|
||||
// User started typing a value; filter value suggestions (if any)
|
||||
if (vals?.length) {
|
||||
return { from, to, options: vals, filter: true };
|
||||
}
|
||||
// No specific values: also show nothing (keeps UI quiet)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Not in a value: suggest field names (and maybe boolean ops)
|
||||
const options: Completion[] = fieldNameCompletions(fieldNames);
|
||||
|
||||
return { from, to, options, filter: true };
|
||||
};
|
||||
}
|
||||
|
||||
const language = LRLanguage.define({
|
||||
name: 'filter',
|
||||
parser,
|
||||
languageData: {
|
||||
autocompletion: {},
|
||||
},
|
||||
});
|
||||
|
||||
/** Public extension */
|
||||
export function filter(options: FilterOptions) {
|
||||
const source = makeCompletionSource(options);
|
||||
return new LanguageSupport(language, [autocompletion({ override: [source] })]);
|
||||
}
|
||||
76
src-web/components/core/Editor/filter/filter.grammar
Normal file
76
src-web/components/core/Editor/filter/filter.grammar
Normal file
@@ -0,0 +1,76 @@
|
||||
@top Query { Expr }
|
||||
|
||||
@skip { space+ }
|
||||
@tokens {
|
||||
space { std.whitespace+ }
|
||||
|
||||
LParen { "(" }
|
||||
RParen { ")" }
|
||||
Colon { ":" }
|
||||
Not { "-" | "NOT" }
|
||||
|
||||
// Keywords (case-insensitive)
|
||||
And { "AND" }
|
||||
Or { "OR" }
|
||||
|
||||
// "quoted phrase" with simple escapes: \" and \\
|
||||
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
||||
|
||||
// field/word characters (keep generous for URLs/paths)
|
||||
Word { $[A-Za-z0-9_]+ }
|
||||
|
||||
@precedence { Not, And, Or, Word }
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
|
||||
// Precedence: NOT (highest) > AND > OR (lowest)
|
||||
// We also allow implicit AND in your parser/evaluator, but for highlighting,
|
||||
// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
|
||||
Expr {
|
||||
OrExpr
|
||||
}
|
||||
|
||||
OrExpr {
|
||||
AndExpr (Or AndExpr)*
|
||||
}
|
||||
|
||||
AndExpr {
|
||||
Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
|
||||
}
|
||||
|
||||
Unary {
|
||||
Not Unary
|
||||
| Primary
|
||||
}
|
||||
|
||||
Primary {
|
||||
Group
|
||||
| Field
|
||||
| Phrase
|
||||
| Term
|
||||
}
|
||||
|
||||
Group {
|
||||
LParen Expr RParen
|
||||
}
|
||||
|
||||
Field {
|
||||
FieldName Colon FieldValue
|
||||
}
|
||||
|
||||
FieldName {
|
||||
Word
|
||||
}
|
||||
|
||||
FieldValue {
|
||||
Phrase
|
||||
| Term
|
||||
| Group
|
||||
}
|
||||
|
||||
Term {
|
||||
Word
|
||||
}
|
||||
|
||||
@external propSource highlight from "./highlight"
|
||||
23
src-web/components/core/Editor/filter/filter.ts
Normal file
23
src-web/components/core/Editor/filter/filter.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {highlight} from "./highlight"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#pQPO,58zOVQPO'#CrO#}QPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
|
||||
stateData: "$`~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~OXQO]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
|
||||
goto: "#hgPPhnryP!YPP!c!o!xPP#RP!cPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd^TOQUWacdRi__TOQUWacd_SOQUWacdRj_Q]PRf]QcWRlcQeXRne",
|
||||
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
|
||||
maxTerm: 25,
|
||||
nodeProps: [
|
||||
["openedBy", 8,"LParen"],
|
||||
["closedBy", 9,"RParen"]
|
||||
],
|
||||
propSources: [highlight],
|
||||
skippedNodes: [0,20],
|
||||
repeatNodeCount: 3,
|
||||
tokenData: ")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
|
||||
tokenizers: [0],
|
||||
topRules: {"Query":[0,1]},
|
||||
tokenPrec: 148
|
||||
})
|
||||
|
||||
24
src-web/components/core/Editor/filter/highlight.ts
Normal file
24
src-web/components/core/Editor/filter/highlight.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
|
||||
export const highlight = styleTags({
|
||||
// Boolean operators
|
||||
And: t.operatorKeyword,
|
||||
Or: t.operatorKeyword,
|
||||
Not: t.operatorKeyword,
|
||||
|
||||
// Structural punctuation
|
||||
LParen: t.paren,
|
||||
RParen: t.paren,
|
||||
Colon: t.punctuation,
|
||||
Minus: t.operator,
|
||||
|
||||
// Literals
|
||||
Phrase: t.string, // "quoted string"
|
||||
Term: t.variableName, // bare terms like foo, bar
|
||||
|
||||
// Fields
|
||||
'FieldName/Word': t.tagName,
|
||||
|
||||
// Grouping
|
||||
Group: t.paren,
|
||||
});
|
||||
298
src-web/components/core/Editor/filter/query.ts
Normal file
298
src-web/components/core/Editor/filter/query.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
// query.ts
|
||||
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
|
||||
|
||||
import { fuzzyMatch } from 'fuzzbunny';
|
||||
/////////////////////////
|
||||
// AST
|
||||
/////////////////////////
|
||||
|
||||
export type Ast =
|
||||
| { type: 'Term'; value: string } // foo
|
||||
| { type: 'Phrase'; value: string } // "hi there"
|
||||
| { type: 'Field'; field: string; value: string } // method:POST or title:"exact phrase"
|
||||
| { type: 'Not'; node: Ast } // -foo or NOT foo
|
||||
| { type: 'And'; left: Ast; right: Ast } // a AND b
|
||||
| { type: 'Or'; left: Ast; right: Ast }; // a OR b
|
||||
|
||||
/////////////////////////
|
||||
// Tokenizer
|
||||
/////////////////////////
|
||||
type Tok =
|
||||
| { kind: 'LPAREN' }
|
||||
| { kind: 'RPAREN' }
|
||||
| { kind: 'AND' }
|
||||
| { kind: 'OR' }
|
||||
| { kind: 'NOT' } // explicit NOT
|
||||
| { kind: 'MINUS' } // unary minus before term/phrase/paren group
|
||||
| { kind: 'COLON' }
|
||||
| { kind: 'WORD'; text: string } // bareword (unquoted)
|
||||
| { kind: 'PHRASE'; text: string } // "quoted phrase"
|
||||
| { kind: 'EOF' };
|
||||
|
||||
const isSpace = (c: string) => /\s/.test(c);
|
||||
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
|
||||
|
||||
export function tokenize(input: string): Tok[] {
|
||||
const toks: Tok[] = [];
|
||||
let i = 0;
|
||||
const n = input.length;
|
||||
|
||||
const peek = () => input[i] ?? '';
|
||||
const advance = () => input[i++];
|
||||
|
||||
const readWord = () => {
|
||||
let s = '';
|
||||
while (i < n && isIdent(peek())) s += advance();
|
||||
return s;
|
||||
};
|
||||
|
||||
const readPhrase = () => {
|
||||
// assumes current char is opening quote
|
||||
advance(); // consume opening "
|
||||
let s = '';
|
||||
while (i < n) {
|
||||
const c = advance();
|
||||
if (c === `"`) break;
|
||||
if (c === '\\' && i < n) {
|
||||
// escape \" and \\ (simple)
|
||||
const next = advance();
|
||||
s += next;
|
||||
} else {
|
||||
s += c;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const c = peek();
|
||||
|
||||
if (isSpace(c)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === '(') {
|
||||
toks.push({ kind: 'LPAREN' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ')') {
|
||||
toks.push({ kind: 'RPAREN' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ':') {
|
||||
toks.push({ kind: 'COLON' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === `"`) {
|
||||
const text = readPhrase();
|
||||
toks.push({ kind: 'PHRASE', text });
|
||||
continue;
|
||||
}
|
||||
if (c === '-') {
|
||||
toks.push({ kind: 'MINUS' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// WORD / AND / OR / NOT
|
||||
if (isIdent(c)) {
|
||||
const w = readWord();
|
||||
const upper = w.toUpperCase();
|
||||
if (upper === 'AND') toks.push({ kind: 'AND' });
|
||||
else if (upper === 'OR') toks.push({ kind: 'OR' });
|
||||
else if (upper === 'NOT') toks.push({ kind: 'NOT' });
|
||||
else toks.push({ kind: 'WORD', text: w });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown char—skip to be forgiving
|
||||
i++;
|
||||
}
|
||||
|
||||
toks.push({ kind: 'EOF' });
|
||||
return toks;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private toks: Tok[]) {}
|
||||
|
||||
private peek(): Tok {
|
||||
return this.toks[this.i] ?? { kind: 'EOF' };
|
||||
}
|
||||
private advance(): Tok {
|
||||
return this.toks[this.i++] ?? { kind: 'EOF' };
|
||||
}
|
||||
private at(kind: Tok['kind']) {
|
||||
return this.peek().kind === kind;
|
||||
}
|
||||
|
||||
// Top-level: parse OR-precedence chain, allowing implicit AND.
|
||||
parse(): Ast | null {
|
||||
if (this.at('EOF')) return null;
|
||||
const expr = this.parseOr();
|
||||
if (!this.at('EOF')) {
|
||||
// Optionally, consume remaining tokens or throw
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
// Precedence: NOT (highest), AND, OR (lowest)
|
||||
private parseOr(): Ast {
|
||||
let node = this.parseAnd();
|
||||
while (this.at('OR')) {
|
||||
this.advance();
|
||||
const rhs = this.parseAnd();
|
||||
node = { type: 'Or', left: node, right: rhs };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private parseAnd(): Ast {
|
||||
let node = this.parseUnary();
|
||||
// Implicit AND: if next token starts a primary, treat as AND.
|
||||
while (this.at('AND') || this.startsPrimary()) {
|
||||
if (this.at('AND')) this.advance();
|
||||
const rhs = this.parseUnary();
|
||||
node = { type: 'And', left: node, right: rhs };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private parseUnary(): Ast {
|
||||
if (this.at('NOT') || this.at('MINUS')) {
|
||||
this.advance();
|
||||
const node = this.parseUnary();
|
||||
return { type: 'Not', node };
|
||||
}
|
||||
return this.parsePrimaryOrField();
|
||||
}
|
||||
|
||||
private startsPrimary(): boolean {
|
||||
const k = this.peek().kind;
|
||||
return k === 'WORD' || k === 'PHRASE' || k === 'LPAREN' || k === 'MINUS' || k === 'NOT';
|
||||
}
|
||||
|
||||
private parsePrimaryOrField(): Ast {
|
||||
// Parenthesized group
|
||||
if (this.at('LPAREN')) {
|
||||
this.advance();
|
||||
const inside = this.parseOr();
|
||||
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
|
||||
this.advance();
|
||||
return inside;
|
||||
}
|
||||
|
||||
// Phrase
|
||||
if (this.at('PHRASE')) {
|
||||
const t = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
|
||||
return { type: 'Phrase', value: t.text };
|
||||
}
|
||||
|
||||
// Field or bare word
|
||||
if (this.at('WORD')) {
|
||||
const wordTok = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||
|
||||
if (this.at('COLON')) {
|
||||
// field:value or field:"phrase"
|
||||
this.advance(); // :
|
||||
let value: string;
|
||||
if (this.at('PHRASE')) {
|
||||
const p = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
|
||||
value = p.text;
|
||||
} else if (this.at('WORD')) {
|
||||
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||
value = w.text;
|
||||
} else {
|
||||
// Anything else after colon is treated literally as a single Term token.
|
||||
const t = this.advance();
|
||||
value = tokText(t);
|
||||
}
|
||||
return { type: 'Field', field: wordTok.text, value };
|
||||
}
|
||||
|
||||
// plain term
|
||||
return { type: 'Term', value: wordTok.text };
|
||||
}
|
||||
|
||||
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
|
||||
return { type: 'Phrase', value: 'text' in w ? w.text : '' };
|
||||
}
|
||||
}
|
||||
|
||||
function tokText(t: Tok): string {
|
||||
if ('text' in t) return t.text;
|
||||
|
||||
switch (t.kind) {
|
||||
case 'COLON':
|
||||
return ':';
|
||||
case 'LPAREN':
|
||||
return '(';
|
||||
case 'RPAREN':
|
||||
return ')';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseQuery(q: string): Ast | null {
|
||||
if (q.trim() === '') return null;
|
||||
const toks = tokenize(q);
|
||||
const parser = new Parser(toks);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
export type Doc = {
|
||||
text?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Technique = 'substring' | 'fuzzy' | 'strict';
|
||||
|
||||
function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
|
||||
if (!hay || !needle) return false;
|
||||
else if (technique === 'strict') return hay === needle;
|
||||
else if (technique === 'fuzzy') return !!fuzzyMatch(hay, needle);
|
||||
else return hay.indexOf(needle) !== -1;
|
||||
}
|
||||
|
||||
export function evaluate(ast: Ast | null, doc: Doc): boolean {
|
||||
if (!ast) return true; // Match everything if no query is provided
|
||||
|
||||
const text = (doc.text ?? '').toLowerCase();
|
||||
const fieldsNorm: Record<string, string[]> = {};
|
||||
|
||||
for (const [k, v] of Object.entries(doc.fields ?? {})) {
|
||||
if (!(typeof v === 'string' || Array.isArray(v))) continue;
|
||||
fieldsNorm[k.toLowerCase()] = Array.isArray(v)
|
||||
? v.filter((v) => typeof v === 'string').map((s) => s.toLowerCase())
|
||||
: [String(v ?? '').toLowerCase()];
|
||||
}
|
||||
|
||||
const evalNode = (node: Ast): boolean => {
|
||||
switch (node.type) {
|
||||
case 'Term':
|
||||
return includes(text, node.value.toLowerCase(), 'fuzzy');
|
||||
case 'Phrase':
|
||||
// Quoted phrases match exactly
|
||||
return includes(text, node.value.toLowerCase(), 'substring');
|
||||
case 'Field': {
|
||||
const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
|
||||
if (vals.length === 0) return false;
|
||||
return vals.some((v) => includes(v, node.value.toLowerCase(), 'substring'));
|
||||
}
|
||||
case 'Not':
|
||||
return !evalNode(node.node);
|
||||
case 'And':
|
||||
return evalNode(node.left) && evalNode(node.right);
|
||||
case 'Or':
|
||||
return evalNode(node.left) || evalNode(node.right);
|
||||
}
|
||||
};
|
||||
|
||||
return evalNode(ast);
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsDownUpIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CircleAlertIcon,
|
||||
CircleDashedIcon,
|
||||
CircleDollarSignIcon,
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
DotIcon,
|
||||
DownloadIcon,
|
||||
EllipsisIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ExpandIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
@@ -150,6 +153,8 @@ const icons = {
|
||||
check_square_unchecked: SquareIcon,
|
||||
chevron_down: ChevronDownIcon,
|
||||
chevron_left: ChevronLeftIcon,
|
||||
chevrons_up_down: ChevronsUpDownIcon,
|
||||
chevrons_down_up: ChevronsDownUpIcon,
|
||||
chevron_right: ChevronRightIcon,
|
||||
circle_alert: CircleAlertIcon,
|
||||
circle_dashed: CircleDashedIcon,
|
||||
@@ -167,6 +172,7 @@ const icons = {
|
||||
dot: DotIcon,
|
||||
download: DownloadIcon,
|
||||
ellipsis: EllipsisIcon,
|
||||
ellipsis_vertical: EllipsisVerticalIcon,
|
||||
expand: ExpandIcon,
|
||||
external_link: ExternalLinkIcon,
|
||||
eye: EyeIcon,
|
||||
|
||||
@@ -75,13 +75,22 @@ export type InputProps = Pick<
|
||||
rightSlot?: ReactNode;
|
||||
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
|
||||
stateKey: EditorProps['stateKey'];
|
||||
extraExtensions?: EditorProps['extraExtensions'];
|
||||
tint?: Color;
|
||||
type?: 'text' | 'password';
|
||||
validate?: boolean | ((v: string) => boolean);
|
||||
wrapLines?: boolean;
|
||||
};
|
||||
|
||||
export const Input = forwardRef<EditorView, InputProps>(function Input({ type, ...props }, ref) {
|
||||
export interface InputHandle {
|
||||
focus: () => void;
|
||||
isFocused: () => boolean;
|
||||
value: () => string;
|
||||
selectAll: () => void;
|
||||
dispatch: EditorView['dispatch'];
|
||||
}
|
||||
|
||||
export const Input = forwardRef<InputHandle, InputProps>(function Input({ type, ...props }, ref) {
|
||||
// If it's a password and template functions are supported (ie. secure(...)) then
|
||||
// use the encrypted input component.
|
||||
if (type === 'password' && props.autocompleteFunctions) {
|
||||
@@ -91,7 +100,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input({ type, .
|
||||
}
|
||||
});
|
||||
|
||||
const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
||||
const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
@@ -132,7 +141,29 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
|
||||
const inputHandle = useMemo<InputHandle>(
|
||||
() => ({
|
||||
focus: () => {
|
||||
editorRef.current?.focus();
|
||||
},
|
||||
isFocused: () => editorRef.current?.hasFocus ?? false,
|
||||
value: () => editorRef.current?.state.doc.toString() ?? '',
|
||||
dispatch: (...args) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
editorRef.current?.dispatch(...(args as any));
|
||||
},
|
||||
selectAll() {
|
||||
const head = editorRef.current?.state.doc.length ?? 0;
|
||||
editorRef.current?.dispatch({
|
||||
selection: { anchor: 0, head },
|
||||
});
|
||||
editorRef.current?.focus();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, (): InputHandle => inputHandle, [inputHandle]);
|
||||
|
||||
const lastWindowFocus = useRef<number>(0);
|
||||
useEffect(() => {
|
||||
@@ -198,6 +229,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
|
||||
console.log('HELLO?');
|
||||
const form = wrapperRef.current?.closest('form');
|
||||
if (!isValid || form == null) return;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { ErrorBoundary } from '../../ErrorBoundary';
|
||||
import type { ButtonProps } from '../Button';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon';
|
||||
import type { RadioDropdownProps } from '../RadioDropdown';
|
||||
import { RadioDropdown } from '../RadioDropdown';
|
||||
@@ -103,18 +105,28 @@ export function Tabs({
|
||||
}
|
||||
|
||||
const isActive = t.value === value;
|
||||
const btnClassName = classNames(
|
||||
'h-sm flex items-center rounded whitespace-nowrap',
|
||||
'!px-2 ml-[1px] hocus:text-text',
|
||||
addBorders && 'border hocus:bg-surface-highlight',
|
||||
isActive ? 'text-text' : 'text-text-subtle',
|
||||
isActive && addBorders
|
||||
? 'border-surface-active bg-surface-active'
|
||||
: layout === 'vertical'
|
||||
? 'border-border-subtle'
|
||||
: 'border-transparent',
|
||||
layout === 'horizontal' && 'flex justify-between min-w-[10rem]',
|
||||
);
|
||||
|
||||
const btnProps: Partial<ButtonProps> = {
|
||||
size: 'sm',
|
||||
color: 'custom',
|
||||
justify: layout === 'horizontal' ? 'start' : 'center',
|
||||
onClick: isActive ? undefined : () => onChangeValue(t.value),
|
||||
className: classNames(
|
||||
'flex items-center rounded whitespace-nowrap',
|
||||
'!px-2 ml-[1px]',
|
||||
'outline-none',
|
||||
'ring-none',
|
||||
'focus-visible-or-class:outline-2',
|
||||
addBorders && 'border focus-visible:bg-surface-highlight',
|
||||
isActive ? 'text-text' : 'text-text-subtle',
|
||||
isActive && addBorders
|
||||
? 'border-surface-active bg-surface-active'
|
||||
: layout === 'vertical'
|
||||
? 'border-border-subtle'
|
||||
: 'border-transparent',
|
||||
layout === 'horizontal' && 'min-w-[10rem]',
|
||||
),
|
||||
};
|
||||
|
||||
if ('options' in t) {
|
||||
const option = t.options.items.find(
|
||||
@@ -129,35 +141,33 @@ export function Tabs({
|
||||
value={t.options.value}
|
||||
onChange={t.options.onChange}
|
||||
>
|
||||
<button
|
||||
onClick={isActive ? undefined : () => onChangeValue(t.value)}
|
||||
className={classNames(btnClassName)}
|
||||
<Button
|
||||
rightSlot={
|
||||
<>
|
||||
{t.rightSlot}
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_down"
|
||||
className={classNames(
|
||||
'ml-1',
|
||||
isActive ? 'text-text-subtle' : 'text-text-subtlest',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
{...btnProps}
|
||||
>
|
||||
{option && 'shortLabel' in option && option.shortLabel
|
||||
? option.shortLabel
|
||||
: (option?.label ?? 'Unknown')}
|
||||
{t.rightSlot}
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_down"
|
||||
className={classNames(
|
||||
'ml-1',
|
||||
isActive ? 'text-text-subtle' : 'text-text-subtlest',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={isActive ? undefined : () => onChangeValue(t.value)}
|
||||
className={btnClassName}
|
||||
>
|
||||
<Button key={t.value} rightSlot={t.rightSlot} {...btnProps}>
|
||||
{t.label}
|
||||
{t.rightSlot}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -15,6 +15,7 @@ import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -25,7 +26,6 @@ import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
|
||||
import { useHotKey } from '../../../hooks/useHotKey';
|
||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { isSidebarFocused } from '../../../lib/scopes';
|
||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||
import { ContextMenu } from '../Dropdown';
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
selectedIdsFamily,
|
||||
} from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
@@ -51,22 +51,14 @@ export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
getItemKey: (item: T) => string;
|
||||
getContextMenu?: (t: TreeHandle, items: T[]) => Promise<ContextMenuProps['items']>;
|
||||
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
|
||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||
className?: string;
|
||||
onActivate?: (item: T) => void;
|
||||
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||
hotkeys?: {
|
||||
actions: Partial<
|
||||
Record<
|
||||
HotkeyAction,
|
||||
{
|
||||
cb: (h: TreeHandle, items: T[]) => void;
|
||||
enable?: boolean | ((h: TreeHandle) => boolean);
|
||||
} & Omit<HotKeyOptions, 'enable'>
|
||||
>
|
||||
>;
|
||||
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>;
|
||||
};
|
||||
getEditOptions?: (item: T) => {
|
||||
defaultValue: string;
|
||||
@@ -77,7 +69,8 @@ export interface TreeProps<T extends { id: string }> {
|
||||
|
||||
export interface TreeHandle {
|
||||
treeId: string;
|
||||
focus: () => void;
|
||||
focus: () => boolean;
|
||||
hasFocus: () => boolean;
|
||||
selectItem: (id: string) => void;
|
||||
renameItem: (id: string) => void;
|
||||
showContextMenu: () => void;
|
||||
@@ -119,10 +112,48 @@ function TreeInner<T extends { id: string }>(
|
||||
setShowContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
||||
const isTreeFocused = useCallback(() => {
|
||||
return treeRef.current?.contains(document.activeElement);
|
||||
}, []);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
|
||||
'.tree-item button[tabindex="0"]',
|
||||
);
|
||||
if ($el == null) {
|
||||
return false;
|
||||
} else {
|
||||
$el?.focus();
|
||||
return true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureTabbableItem = useCallback(() => {
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
|
||||
if (lastSelectedItem == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const closest = closestVisibleNode(treeId, lastSelectedItem.node);
|
||||
if (closest != null && closest !== lastSelectedItem.node) {
|
||||
const id = closest.item.id;
|
||||
jotaiStore.set(selectedIdsFamily(treeId), [id]);
|
||||
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
}
|
||||
}, [selectableItems, treeId]);
|
||||
|
||||
// Ensure there's always a tabbable item after collapsed state changes
|
||||
useEffect(() => {
|
||||
const unsub = jotaiStore.sub(collapsedFamily(treeId), ensureTabbableItem);
|
||||
return unsub;
|
||||
}, [ensureTabbableItem, isTreeFocused, selectableItems, treeId, tryFocus]);
|
||||
|
||||
// Ensure there's always a tabbable item after render
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(ensureTabbableItem);
|
||||
});
|
||||
|
||||
const setSelected = useCallback(
|
||||
function setSelected(ids: string[], focus: boolean) {
|
||||
jotaiStore.set(selectedIdsFamily(treeId), ids);
|
||||
@@ -136,6 +167,7 @@ function TreeInner<T extends { id: string }>(
|
||||
() => ({
|
||||
treeId,
|
||||
focus: tryFocus,
|
||||
hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false,
|
||||
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
||||
selectItem: (id) => {
|
||||
setSelected([id], false);
|
||||
@@ -144,7 +176,7 @@ function TreeInner<T extends { id: string }>(
|
||||
showContextMenu: async () => {
|
||||
if (getContextMenu == null) return;
|
||||
const items = getSelectedItems(treeId, selectableItems);
|
||||
const menuItems = await getContextMenu(treeHandle, items);
|
||||
const menuItems = await getContextMenu(items);
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
|
||||
if (rect == null) return;
|
||||
@@ -163,16 +195,16 @@ function TreeInner<T extends { id: string }>(
|
||||
const isSelected = items.find((i) => i.id === item.id);
|
||||
if (isSelected) {
|
||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||
return getContextMenu(treeHandle, items);
|
||||
return getContextMenu(items);
|
||||
} else {
|
||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||
// Also update the selection with it
|
||||
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
return getContextMenu(treeHandle, [item]);
|
||||
return getContextMenu([item]);
|
||||
}
|
||||
};
|
||||
}, [getContextMenu, selectableItems, treeHandle, treeId]);
|
||||
}, [getContextMenu, selectableItems, treeId]);
|
||||
|
||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||
@@ -282,7 +314,7 @@ function TreeInner<T extends { id: string }>(
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
|
||||
(e) => {
|
||||
if (!isSidebarFocused()) return;
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectPrevItem(e);
|
||||
},
|
||||
@@ -293,7 +325,7 @@ function TreeInner<T extends { id: string }>(
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
|
||||
(e) => {
|
||||
if (!isSidebarFocused()) return;
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
selectNextItem(e);
|
||||
},
|
||||
@@ -305,7 +337,7 @@ function TreeInner<T extends { id: string }>(
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowRight' || e.key === 'l',
|
||||
(e) => {
|
||||
if (!isSidebarFocused()) return;
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||
@@ -331,7 +363,7 @@ function TreeInner<T extends { id: string }>(
|
||||
useKey(
|
||||
(e) => e.key === 'ArrowLeft' || e.key === 'h',
|
||||
(e) => {
|
||||
if (!isSidebarFocused()) return;
|
||||
if (!isTreeFocused()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||
@@ -348,7 +380,7 @@ function TreeInner<T extends { id: string }>(
|
||||
selectParentItem(e);
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ options: {} },
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
@@ -544,22 +576,17 @@ function TreeInner<T extends { id: string }>(
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = await getContextMenu(treeHandle, []);
|
||||
const items = await getContextMenu([]);
|
||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[getContextMenu, treeHandle],
|
||||
[getContextMenu],
|
||||
);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<TreeHotKeys
|
||||
treeHandle={treeHandle}
|
||||
treeId={treeId}
|
||||
hotkeys={hotkeys}
|
||||
selectableItems={selectableItems}
|
||||
/>
|
||||
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
||||
{showContextMenu && (
|
||||
<ContextMenu
|
||||
items={showContextMenu.items}
|
||||
@@ -655,10 +682,9 @@ interface TreeHotKeyProps<T extends { id: string }> {
|
||||
action: HotkeyAction;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
treeId: string;
|
||||
onDone: (h: TreeHandle, items: T[]) => void;
|
||||
treeHandle: TreeHandle;
|
||||
onDone: (items: T[]) => void;
|
||||
priority?: number;
|
||||
enable?: boolean | ((h: TreeHandle) => boolean);
|
||||
enable?: boolean | (() => boolean);
|
||||
}
|
||||
|
||||
function TreeHotKey<T extends { id: string }>({
|
||||
@@ -666,20 +692,19 @@ function TreeHotKey<T extends { id: string }>({
|
||||
action,
|
||||
onDone,
|
||||
selectableItems,
|
||||
treeHandle,
|
||||
enable,
|
||||
...options
|
||||
}: TreeHotKeyProps<T>) {
|
||||
useHotKey(
|
||||
action,
|
||||
() => {
|
||||
onDone(treeHandle, getSelectedItems(treeId, selectableItems));
|
||||
onDone(getSelectedItems(treeId, selectableItems));
|
||||
},
|
||||
{
|
||||
...options,
|
||||
enable: () => {
|
||||
if (enable == null) return true;
|
||||
if (typeof enable === 'function') return enable(treeHandle);
|
||||
if (typeof enable === 'function') return enable();
|
||||
else return enable;
|
||||
},
|
||||
},
|
||||
@@ -691,12 +716,10 @@ function TreeHotKeys<T extends { id: string }>({
|
||||
treeId,
|
||||
hotkeys,
|
||||
selectableItems,
|
||||
treeHandle,
|
||||
}: {
|
||||
treeId: string;
|
||||
hotkeys: TreeProps<T>['hotkeys'];
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
treeHandle: TreeHandle;
|
||||
}) {
|
||||
if (hotkeys == null) return null;
|
||||
|
||||
@@ -708,7 +731,6 @@ function TreeHotKeys<T extends { id: string }>({
|
||||
action={hotkey as HotkeyAction}
|
||||
treeId={treeId}
|
||||
onDone={cb}
|
||||
treeHandle={treeHandle}
|
||||
selectableItems={selectableItems}
|
||||
{...options}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface TreeItemHandle {
|
||||
rename: () => void;
|
||||
isRenaming: boolean;
|
||||
rect: () => DOMRect;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||
@@ -62,9 +63,11 @@ function TreeItem_<T extends { id: string }>({
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
addRef?.(node.item, {
|
||||
const handle = useMemo<TreeItemHandle>(
|
||||
() => ({
|
||||
focus: () => {
|
||||
draggableRef.current?.focus();
|
||||
},
|
||||
rename: () => {
|
||||
if (getEditOptions != null) {
|
||||
setEditing(true);
|
||||
@@ -77,8 +80,13 @@ function TreeItem_<T extends { id: string }>({
|
||||
}
|
||||
return listItemRef.current.getBoundingClientRect();
|
||||
},
|
||||
});
|
||||
}, [addRef, editing, getEditOptions, node.item]);
|
||||
}),
|
||||
[editing, getEditOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
addRef?.(node.item, handle);
|
||||
}, [addRef, handle, node.item]);
|
||||
|
||||
const ancestorIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
@@ -110,27 +118,21 @@ function TreeItem_<T extends { id: string }>({
|
||||
} | null>(null);
|
||||
|
||||
useEffect(
|
||||
function scrollIntoViewWhenSelected() {
|
||||
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||
() =>
|
||||
jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||
listItemRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
},
|
||||
}),
|
||||
[node.item.id, treeId],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
function handleClick(e: MouseEvent<HTMLButtonElement>) {
|
||||
onClick?.(node.item, e);
|
||||
},
|
||||
(e: MouseEvent<HTMLButtonElement>) => onClick?.(node.item, e),
|
||||
[node, onClick],
|
||||
);
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
function toggleCollapsed() {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
||||
},
|
||||
[node.item.id, treeId],
|
||||
);
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
||||
}, [node.item.id, treeId]);
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async function submitNameEdit(el: HTMLInputElement) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { selectedIdsFamily } from './atoms';
|
||||
import { collapsedFamily, selectedIdsFamily } from './atoms';
|
||||
|
||||
export interface TreeNode<T extends { id: string }> {
|
||||
children?: TreeNode<T>[];
|
||||
@@ -52,3 +52,26 @@ export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancesto
|
||||
// Check parents recursively
|
||||
return hasAncestor(node.parent, ancestorId);
|
||||
}
|
||||
|
||||
export function isVisibleNode<T extends { id: string }>(treeId: string, node: TreeNode<T>) {
|
||||
const collapsed = jotaiStore.get(collapsedFamily(treeId));
|
||||
let p = node.parent;
|
||||
while (p) {
|
||||
if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node
|
||||
p = p.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closestVisibleNode<T extends { id: string }>(
|
||||
treeId: string,
|
||||
node: TreeNode<T>,
|
||||
): TreeNode<T> | null {
|
||||
let n: TreeNode<T> | null = node;
|
||||
while (n) {
|
||||
if (isVisibleNode(treeId, n) && !n.hidden) return n;
|
||||
if (n.parent == null) return null;
|
||||
n = n.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export enum ItemTypes {
|
||||
TREE_ITEM = 'tree.item',
|
||||
TREE = 'tree',
|
||||
}
|
||||
|
||||
export type DragItem = {
|
||||
id: string;
|
||||
};
|
||||
Reference in New Issue
Block a user