Tree fixes and sidebar filter DSL

This commit is contained in:
Gregory Schier
2025-10-31 05:59:46 -07:00
parent 8d8e5c0317
commit 2cdd1d8136
21 changed files with 1218 additions and 342 deletions

View File

@@ -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 (

View File

@@ -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() {

View File

@@ -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];

View 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] })]);
}

View 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"

View 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
})

View 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,
});

View 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);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>
);
}
})}

View File

@@ -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}
/>

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -1,8 +0,0 @@
export enum ItemTypes {
TREE_ITEM = 'tree.item',
TREE = 'tree',
}
export type DragItem = {
id: string;
};