feat: Third pass aligning core experience with IntelliJ IDEA
Project & files - Project view modes (Project Files / Open Files / All Changed Files / Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go, Maven/Gradle jars; read-only jar/zip browsing - ⌘N New popup with type search and more file kinds; New Project, Rename Project, Reload All from Disk, Export/Import Settings, Power Save Mode - File type associations, extra content roots and exclude patterns (indexed and watched without reopening the project) Editor & navigation - Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline, history; Find in Files: Module scope, custom pattern scopes, multiline - Search Everywhere filters, Assign Shortcut, settings results; named bookmark lists, file/folder bookmarks; Structure filters and Show Inherited; Recent Locations timestamps and breadcrumbs; Code Vision author - Completion options, editor tab placement/layout, inline completion (LSP 3.18) - Spell checking (Typo inspection, ide-spell) and Inspections settings with custom profiles; Code Style schemes, formatter markers, Do not format Run, VCS, services, database - Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker, Compound), Modify options, Before launch, env dialog, Current File, Emulate terminal (PTY) - Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees - Generic Services host (Docker connections/registries, run sessions); TODO scope/changes tabs with changelists; Coverage report import - Data sources: wire-compatible vendors, URL import, SSL certs, Options, Schemas and Advanced tabs, color and comment Workbench - Run Anything, Customize Main Toolbar, named tool window layouts, menu completeness, Code Cleanup, macros, "Don't Show Again", editor banners - Chinese translations for the new UI Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
No files matched your search
@@ -64,6 +64,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
onClick,
|
||||
disabled,
|
||||
type = "button",
|
||||
"aria-label": ariaLabel,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@@ -76,7 +77,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
aria-label={tx(ariaLabel) ?? label}
|
||||
aria-pressed={pressed}
|
||||
aria-keyshortcuts={keys}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -93,6 +93,15 @@
|
||||
line-height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
/* A multi-line capable field holding one line looks like a text input. */
|
||||
.searchAreaSingle {
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leading:is(button) {
|
||||
margin-right: 2px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.invalid .searchInput {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode, type Ref, type TextareaHTMLAttributes } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
type InputHTMLAttributes,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { IconButton } from "./Button";
|
||||
import styles from "./Input.module.css";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
@@ -13,7 +24,7 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ invalid, mono, variant = "framed", className, spellCheck = false, autoComplete = "off", placeholder, ...rest },
|
||||
{ invalid, mono, variant = "framed", className, spellCheck = false, autoComplete = "off", placeholder, "aria-label": ariaLabel, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const tx = useTx();
|
||||
@@ -26,6 +37,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
autoCapitalize="off"
|
||||
aria-invalid={invalid || undefined}
|
||||
placeholder={tx(placeholder)}
|
||||
aria-label={tx(ariaLabel)}
|
||||
className={clsx(styles.input, variant === "bare" ? styles.bare : styles.framed, mono && styles.mono, className)}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -54,6 +66,32 @@ export interface SearchFieldProps extends Omit<InputHTMLAttributes<HTMLInputElem
|
||||
size?: "normal" | "large";
|
||||
variant?: "framed" | "bare";
|
||||
className?: string;
|
||||
/**
|
||||
* IDEA's multi-line search field: always a text area that grows with the
|
||||
* text, plus a New Line button (⇧⌘↵ / ⇧Ctrl↵ inserts one too).
|
||||
*/
|
||||
multiline?: boolean;
|
||||
/** Turns the leading magnifier into IDEA's search-history button (magnifier ▾). */
|
||||
onHistory?: () => void;
|
||||
}
|
||||
|
||||
const isNewLineKey = (e: KeyboardEvent) => e.key === "Enter" && e.shiftKey && (e.metaKey || e.ctrlKey) && !e.altKey;
|
||||
|
||||
interface FieldCommands {
|
||||
newLine?: () => void;
|
||||
history?: () => void;
|
||||
}
|
||||
let focusedField: FieldCommands | null = null;
|
||||
|
||||
/**
|
||||
* New Line / Recent Searches in the focused search field, for the shortcut
|
||||
* actions that win over editor bindings of the same keys (contexts
|
||||
* `searchFieldNewLine` / `searchFieldHistory`).
|
||||
*/
|
||||
export function runSearchFieldCommand(command: keyof FieldCommands): boolean {
|
||||
const run = focusedField?.[command];
|
||||
run?.();
|
||||
return !!run;
|
||||
}
|
||||
|
||||
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(
|
||||
@@ -69,13 +107,70 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
|
||||
variant = "framed",
|
||||
className,
|
||||
placeholder,
|
||||
multiline,
|
||||
onHistory,
|
||||
"aria-label": ariaLabel,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const tx = useTx();
|
||||
const inner = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
const setRef = (el: HTMLInputElement | HTMLTextAreaElement | null) => {
|
||||
inner.current = el;
|
||||
if (typeof ref === "function") ref(el as HTMLInputElement);
|
||||
else if (ref) ref.current = el as HTMLInputElement;
|
||||
};
|
||||
const insertNewLine = () => {
|
||||
const el = inner.current;
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
onChange(`${value.slice(0, start)}\n${value.slice(end)}`);
|
||||
setTimeout(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(start + 1, start + 1);
|
||||
}, 0);
|
||||
};
|
||||
const commands = useRef<FieldCommands>({});
|
||||
commands.current.newLine = multiline ? insertNewLine : undefined;
|
||||
commands.current.history = onHistory;
|
||||
const { onKeyDown, onFocus, onBlur, ...fieldProps } = rest;
|
||||
const keyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (multiline && isNewLineKey(e)) {
|
||||
e.preventDefault();
|
||||
insertNewLine();
|
||||
return;
|
||||
}
|
||||
onKeyDown?.(e);
|
||||
};
|
||||
const focus = (e: FocusEvent<HTMLInputElement>) => {
|
||||
focusedField = commands.current;
|
||||
setContext("searchFieldNewLine", !!multiline);
|
||||
setContext("searchFieldHistory", !!onHistory);
|
||||
onFocus?.(e);
|
||||
};
|
||||
const blur = (e: FocusEvent<HTMLInputElement>) => {
|
||||
if (focusedField === commands.current) {
|
||||
focusedField = null;
|
||||
setContext("searchFieldNewLine", false);
|
||||
setContext("searchFieldHistory", false);
|
||||
}
|
||||
onBlur?.(e);
|
||||
};
|
||||
const handlers = { onKeyDown: keyDown, onFocus: focus, onBlur: blur };
|
||||
// Removing a focused field fires no blur.
|
||||
useEffect(() => {
|
||||
const own = commands.current;
|
||||
return () => {
|
||||
if (focusedField !== own) return;
|
||||
focusedField = null;
|
||||
setContext("searchFieldNewLine", false);
|
||||
setContext("searchFieldHistory", false);
|
||||
};
|
||||
}, []);
|
||||
// A multi-line query (e.g. Find over a multi-line selection) is shown as such.
|
||||
const lines = value.split("\n").length;
|
||||
const iconSize = size === "large" ? 15 : 14;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -88,25 +183,42 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
|
||||
)}
|
||||
data-invalid={invalid || undefined}
|
||||
>
|
||||
{leadingIcon && <Icon name={leadingIcon} size={size === "large" ? 15 : 14} className={styles.leading} />}
|
||||
{lines > 1 ? (
|
||||
{onHistory ? (
|
||||
<IconButton
|
||||
icon="search-history"
|
||||
title="Recent Searches"
|
||||
shortcut="⌥↓"
|
||||
size="small"
|
||||
iconSize={iconSize}
|
||||
tabIndex={-1}
|
||||
className={styles.leading}
|
||||
onClick={onHistory}
|
||||
/>
|
||||
) : (
|
||||
leadingIcon && <Icon name={leadingIcon} size={iconSize} className={styles.leading} />
|
||||
)}
|
||||
{lines > 1 || multiline ? (
|
||||
<textarea
|
||||
ref={ref as unknown as Ref<HTMLTextAreaElement>}
|
||||
className={clsx(styles.searchInput, styles.searchArea)}
|
||||
ref={setRef as Ref<HTMLTextAreaElement>}
|
||||
className={clsx(styles.searchInput, styles.searchArea, lines === 1 && styles.searchAreaSingle)}
|
||||
rows={Math.min(lines, 5)}
|
||||
wrap="off"
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-multiline={multiline || undefined}
|
||||
placeholder={tx(placeholder)}
|
||||
aria-label={tx(ariaLabel)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...(rest as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
{...(handlers as unknown as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
{...(fieldProps as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
ref={ref}
|
||||
ref={setRef as Ref<HTMLInputElement>}
|
||||
className={styles.searchInput}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
@@ -115,14 +227,27 @@ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(functi
|
||||
autoCapitalize="off"
|
||||
aria-invalid={invalid || undefined}
|
||||
placeholder={tx(placeholder)}
|
||||
aria-label={tx(ariaLabel)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...rest}
|
||||
{...handlers}
|
||||
{...fieldProps}
|
||||
/>
|
||||
)}
|
||||
{trailing}
|
||||
{clearable && value && (
|
||||
<IconButton icon="close" title="Clear" size="small" iconSize={12} tabIndex={-1} onClick={() => onChange("")} />
|
||||
)}
|
||||
{multiline && (
|
||||
<IconButton
|
||||
icon="new-line"
|
||||
title="New Line"
|
||||
shortcut="⇧⌘↵"
|
||||
size="small"
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={insertNewLine}
|
||||
/>
|
||||
)}
|
||||
{toggles?.map((t) => (
|
||||
<IconButton
|
||||
key={t.id}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Select<V extends string = string>({
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={aria["aria-label"]}
|
||||
aria-label={tx(aria["aria-label"])}
|
||||
disabled={disabled}
|
||||
className={clsx(styles.trigger, styles[variant], className)}
|
||||
onClick={() => (open ? setOpen(false) : openPopup())}
|
||||
@@ -148,7 +148,7 @@ export function Select<V extends string = string>({
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={24}
|
||||
aria-label={aria["aria-label"]}
|
||||
aria-label={tx(aria["aria-label"])}
|
||||
className={styles.list}
|
||||
renderItem={(o) => (
|
||||
<>
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
background: var(--bg-tab-selected);
|
||||
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
|
||||
}
|
||||
.tinted,
|
||||
.tinted:hover {
|
||||
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), transparent);
|
||||
}
|
||||
.tinted.selected {
|
||||
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--bg-tab-selected));
|
||||
}
|
||||
.tabButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -125,6 +132,98 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Tabs that don't fit: squeezed (labels ellipsized) or wrapped into rows. */
|
||||
.squeeze .scroller {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.squeeze .tab {
|
||||
flex: 0 1 auto;
|
||||
min-width: 40px;
|
||||
}
|
||||
.squeeze .tabButton {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.squeeze .tabLabel {
|
||||
min-width: 0;
|
||||
}
|
||||
.wrap {
|
||||
height: auto;
|
||||
min-height: var(--h-header);
|
||||
}
|
||||
.wrap .scroller {
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
row-gap: 2px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.wrap .tab {
|
||||
height: calc(var(--h-header) - 6px);
|
||||
}
|
||||
|
||||
/* Vertical strip (editor tabs placed left / right): a scrolling column of full-width tabs. */
|
||||
.vertical {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
.vertical .scroller {
|
||||
flex: 0 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.vertical .scroller::-webkit-scrollbar {
|
||||
display: initial;
|
||||
}
|
||||
.vertical .tab {
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.vertical .tabButton {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.vertical .tabLabel {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.vertical .trailing {
|
||||
flex: none;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.vertical .dropBefore::before,
|
||||
.vertical .dropAfter::after {
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
}
|
||||
.vertical .dropBefore::before {
|
||||
top: -2px;
|
||||
}
|
||||
.vertical .dropAfter::after {
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
/* Close button on the left of the label */
|
||||
.closeLeft .closeSlot {
|
||||
order: -1;
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
.closeLeft .closable .tabButton {
|
||||
padding: 0 10px 0 2px;
|
||||
}
|
||||
|
||||
/* drag & drop */
|
||||
.dragging {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { dropIndex, revealHorizontally, TabStripRegistry, Tabs, type TabStripHandle } from "./Tabs";
|
||||
import { dropIndex, revealHorizontally, revealVertically, TabStripRegistry, Tabs, type TabStripHandle } from "./Tabs";
|
||||
|
||||
describe("dropIndex", () => {
|
||||
it("finds the insertion index from tab midpoints", () => {
|
||||
@@ -41,6 +41,18 @@ describe("revealHorizontally", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("revealVertically", () => {
|
||||
it("scrolls a column of tabs just enough to show the tab", () => {
|
||||
const el = (top: number, bottom: number, scrollTop = 0) =>
|
||||
({ scrollTop, getBoundingClientRect: () => ({ top, bottom }) }) as unknown as HTMLElement;
|
||||
const scroller = el(0, 300, 50);
|
||||
revealVertically(scroller, el(290, 314));
|
||||
expect(scroller.scrollTop).toBe(64);
|
||||
revealVertically(scroller, el(-20, 4));
|
||||
expect(scroller.scrollTop).toBe(44);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tabs interactions", () => {
|
||||
const items = [
|
||||
{ id: "a", label: "A" },
|
||||
@@ -76,4 +88,30 @@ describe("Tabs interactions", () => {
|
||||
unmount();
|
||||
expect(handles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a vertical strip moves with ↑/↓, shows actions above the tabs and has no overflow dropdown", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<Tabs
|
||||
items={items}
|
||||
activeId="a"
|
||||
onSelect={onSelect}
|
||||
orientation="vertical"
|
||||
aria-label="Vertical"
|
||||
trailing={<button type="button">Actions</button>}
|
||||
/>,
|
||||
);
|
||||
const list = screen.getByRole("tablist", { name: "Vertical" });
|
||||
expect(list.getAttribute("aria-orientation")).toBe("vertical");
|
||||
const [first] = screen.getAllByRole("tab");
|
||||
fireEvent.keyDown(first!, { key: "ArrowDown" });
|
||||
expect(onSelect).toHaveBeenLastCalledWith("b");
|
||||
fireEvent.keyDown(first!, { key: "ArrowUp" });
|
||||
expect(onSelect).toHaveBeenLastCalledWith("c");
|
||||
fireEvent.keyDown(first!, { key: "ArrowRight" });
|
||||
expect(onSelect).toHaveBeenCalledTimes(2);
|
||||
const actions = screen.getByRole("button", { name: "Actions" });
|
||||
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Show Hidden Tabs" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tab strip with the design's accent underline. Roving focus (←/→, Home/End),
|
||||
* closable tabs (button, middle click, Delete), horizontal wheel scrolling and
|
||||
* an overflow dropdown listing every tab when they don't fit.
|
||||
* Tab strip with the design's accent underline. Roving focus (←/→ or ↑/↓ when
|
||||
* vertical, Home/End), closable tabs (button, middle click, Delete), wheel
|
||||
* scrolling and an overflow dropdown listing every tab when they don't fit.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
@@ -35,6 +36,8 @@ export interface TabItem {
|
||||
modified?: boolean;
|
||||
/** CSS colour for the label (VCS status etc.), e.g. `var(--vcs-modified)`. */
|
||||
color?: string;
|
||||
/** CSS colour the tab background is tinted with (IDEA's data source / file colours). */
|
||||
tint?: string;
|
||||
italic?: boolean;
|
||||
tooltip?: string;
|
||||
/** Trailing content (counts, badges). */
|
||||
@@ -91,6 +94,12 @@ export interface TabsProps {
|
||||
panelIdPrefix?: string;
|
||||
/** Enables drag & drop reordering (and moving between strips of the same type). */
|
||||
dnd?: TabsDnd;
|
||||
/** Tabs that don't fit: `scroll` the strip (default), `squeeze` them, or `wrap` into rows. */
|
||||
layout?: "scroll" | "squeeze" | "wrap";
|
||||
/** Side of the label the close button sits on. */
|
||||
closePosition?: "right" | "left";
|
||||
/** `vertical`: a column of tabs (editor tabs placed left / right); always scrolls, `layout` is ignored. */
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
@@ -106,8 +115,12 @@ export function Tabs({
|
||||
className,
|
||||
panelIdPrefix,
|
||||
dnd,
|
||||
layout = "scroll",
|
||||
closePosition = "right",
|
||||
orientation = "horizontal",
|
||||
...aria
|
||||
}: TabsProps) {
|
||||
const vertical = orientation === "vertical";
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
@@ -136,12 +149,12 @@ export function Tabs({
|
||||
);
|
||||
|
||||
const accepts = (e: DragEvent) => !!dnd && [...e.dataTransfer.types].includes(dnd.type);
|
||||
const indexAt = (x: number) =>
|
||||
const indexAt = (e: DragEvent) =>
|
||||
dropIndex(
|
||||
x,
|
||||
vertical ? e.clientY : e.clientX,
|
||||
items.map((it) => {
|
||||
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
|
||||
return { left: r?.left ?? 0, width: r?.width ?? 0 };
|
||||
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
|
||||
}),
|
||||
);
|
||||
const dropHandlers = dnd
|
||||
@@ -150,7 +163,7 @@ export function Tabs({
|
||||
if (!accepts(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const i = indexAt(e.clientX);
|
||||
const i = indexAt(e);
|
||||
if (i !== drop) setDrop(i);
|
||||
},
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -160,7 +173,7 @@ export function Tabs({
|
||||
if (!accepts(e)) return;
|
||||
e.preventDefault();
|
||||
const payload = e.dataTransfer.getData(dnd.type);
|
||||
const i = indexAt(e.clientX);
|
||||
const i = indexAt(e);
|
||||
setDrop(null);
|
||||
if (payload) dnd.onDrop(payload, i);
|
||||
},
|
||||
@@ -170,20 +183,21 @@ export function Tabs({
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setOverflow(el.scrollWidth > el.clientWidth + 1);
|
||||
// A vertical strip shows its scrollbar instead of the overflow dropdown.
|
||||
const measure = () => setOverflow(!vertical && el.scrollWidth > el.clientWidth + 1);
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [items.length]);
|
||||
}, [items.length, vertical]);
|
||||
|
||||
useEffect(() => {
|
||||
// The whole tab (label + close button) into view, without scrolling any outer container.
|
||||
const tab = activeId ? tabRefs.current.get(activeId)?.parentElement : null;
|
||||
const scroller = scrollRef.current;
|
||||
if (tab && scroller) revealHorizontally(scroller, tab);
|
||||
}, [activeId]);
|
||||
if (tab && scroller) (vertical ? revealVertically : revealHorizontally)(scroller, tab);
|
||||
}, [activeId, vertical]);
|
||||
|
||||
const focusTab = (index: number) => {
|
||||
const item = items[(index + items.length) % items.length];
|
||||
@@ -195,10 +209,10 @@ export function Tabs({
|
||||
const onKeyDown = (e: KeyboardEvent, index: number, item: TabItem) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case vertical ? "ArrowDown" : "ArrowRight":
|
||||
focusTab(index + 1);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case vertical ? "ArrowUp" : "ArrowLeft":
|
||||
focusTab(index - 1);
|
||||
break;
|
||||
case "Home":
|
||||
@@ -227,15 +241,26 @@ export function Tabs({
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.strip, styles[variant], className)} {...dropHandlers}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.strip,
|
||||
styles[variant],
|
||||
vertical ? styles.vertical : layout !== "scroll" && styles[layout],
|
||||
closePosition === "left" && styles.closeLeft,
|
||||
className,
|
||||
)}
|
||||
{...dropHandlers}
|
||||
>
|
||||
{vertical && trailing && <div className={styles.trailing}>{trailing}</div>}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="tablist"
|
||||
aria-label={aria["aria-label"]}
|
||||
aria-label={tx(aria["aria-label"])}
|
||||
aria-orientation={vertical ? "vertical" : undefined}
|
||||
className={styles.scroller}
|
||||
onWheel={(e) => {
|
||||
const el = scrollRef.current;
|
||||
if (el && Math.abs(e.deltaY) > Math.abs(e.deltaX)) el.scrollLeft += e.deltaY;
|
||||
if (el && !vertical && Math.abs(e.deltaY) > Math.abs(e.deltaX)) el.scrollLeft += e.deltaY;
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
@@ -251,7 +276,9 @@ export function Tabs({
|
||||
dragging === item.id && styles.dragging,
|
||||
drop === index && styles.dropBefore,
|
||||
drop === items.length && index === items.length - 1 && styles.dropAfter,
|
||||
item.tint && styles.tinted,
|
||||
)}
|
||||
style={item.tint ? ({ "--tab-tint": item.tint } as CSSProperties) : undefined}
|
||||
draggable={!!dnd}
|
||||
onDragStart={
|
||||
dnd
|
||||
@@ -339,7 +366,7 @@ export function Tabs({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{trailing && <div className={styles.trailing}>{trailing}</div>}
|
||||
{!vertical && trailing && <div className={styles.trailing}>{trailing}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -351,3 +378,11 @@ export function revealHorizontally(scroller: HTMLElement, child: HTMLElement) {
|
||||
if (c.left < s.left) scroller.scrollLeft -= s.left - c.left;
|
||||
else if (c.right > s.right) scroller.scrollLeft += Math.min(c.right - s.right, c.left - s.left);
|
||||
}
|
||||
|
||||
/** Scrolls `scroller` vertically just enough to show all of `child`. */
|
||||
export function revealVertically(scroller: HTMLElement, child: HTMLElement) {
|
||||
const s = scroller.getBoundingClientRect();
|
||||
const c = child.getBoundingClientRect();
|
||||
if (c.top < s.top) scroller.scrollTop -= s.top - c.top;
|
||||
else if (c.bottom > s.bottom) scroller.scrollTop += Math.min(c.bottom - s.bottom, c.top - s.top);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { fuzzyMatch } from "./match";
|
||||
import { acquirePopup } from "./popupState";
|
||||
import styles from "./Tree.module.css";
|
||||
import { T } from "./T";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export interface TreeRowState {
|
||||
depth: number;
|
||||
@@ -466,7 +467,7 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="tree"
|
||||
aria-label={props["aria-label"]}
|
||||
aria-label={tx(props["aria-label"])}
|
||||
aria-multiselectable={multiSelect || undefined}
|
||||
aria-activedescendant={
|
||||
leadRow && !leadRow.loading && virtualizer.getVirtualItems().some((v) => v.index === leadIndex)
|
||||
|
||||
@@ -182,6 +182,8 @@ const ICONS = {
|
||||
"text-match": "openidea/nodes/textMatch",
|
||||
"open-in-tool-window": "general/openInToolWindow",
|
||||
"preserve-case": "openidea/inline/preserveCase",
|
||||
"new-line": "openidea/inline/newLine",
|
||||
"search-history": "openidea/inline/searchHistory",
|
||||
filter: "general/filter",
|
||||
refresh: "general/refresh",
|
||||
settings: "general/settings",
|
||||
@@ -253,6 +255,18 @@ const ICONS = {
|
||||
"db-sqlite": "openidea/database/sqlite",
|
||||
"db-postgres": "openidea/database/postgres",
|
||||
"db-mysql": "openidea/database/mysql",
|
||||
"db-mariadb": "openidea/database/mariadb",
|
||||
"db-aurora": "openidea/database/aurora",
|
||||
"db-redshift": "openidea/database/redshift",
|
||||
"db-cockroach": "openidea/database/cockroach",
|
||||
"db-greenplum": "openidea/database/greenplum",
|
||||
"db-neon": "openidea/database/neon",
|
||||
"db-oceanbase": "openidea/database/oceanbase",
|
||||
"db-singlestore": "openidea/database/singlestore",
|
||||
"db-supabase": "openidea/database/supabase",
|
||||
"db-tidb": "openidea/database/tidb",
|
||||
"db-timescale": "openidea/database/timescale",
|
||||
"db-yugabyte": "openidea/database/yugabyte",
|
||||
export: "openidea/general/export",
|
||||
web: "openidea/general/web",
|
||||
"browser-chrome": "openidea/browsers/chrome",
|
||||
@@ -790,8 +804,17 @@ const EXT_ICONS = table({
|
||||
/** Source files for languages without a file-type icon. */
|
||||
const CODE_EXT = new Set(["astro", "nim", "wit", "wat", "sol", "v", "sv"]);
|
||||
|
||||
let iconOverride: ((fileName: string) => IconName | null) | null = null;
|
||||
|
||||
/** Lets the user's file type associations (Settings › File Types) pick icons first. */
|
||||
export function setFileIconOverride(resolve: ((fileName: string) => IconName | null) | null) {
|
||||
iconOverride = resolve;
|
||||
}
|
||||
|
||||
/** Picks a file-type icon from a file name. */
|
||||
export function fileIconName(fileName: string): IconName {
|
||||
const custom = iconOverride?.(fileName);
|
||||
if (custom) return custom;
|
||||
const lower = fileName.toLowerCase();
|
||||
const named = FILE_NAMES[lower] ?? NAME_PATTERNS.find(([re]) => re.test(lower))?.[1];
|
||||
if (named) return named;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Amazon Aurora data source (cylinder) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 4V12C2.5 13.1 5 14 8 14C11 14 13.5 13.1 13.5 12V4" fill="#3B48CC"/><ellipse cx="8" cy="4" rx="5.5" ry="2" fill="#5A6BEF"/><path d="M2.5 8C2.5 9.1 5 10 8 10C11 10 13.5 9.1 13.5 8" stroke="#FFFFFF" stroke-width="0.8"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 390 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: CockroachDB data source (insect mark) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4.2C6.6 2.6 4.6 1.9 2.6 2.2C2.4 5.6 4.4 8.7 8 10.4C11.6 8.7 13.6 5.6 13.4 2.2C11.4 1.9 9.4 2.6 8 4.2Z" stroke="#6933FF" stroke-width="1.3" stroke-linejoin="round"/><path d="M8 4.2V14" stroke="#6933FF" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 417 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Greenplum data source (ring) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6" stroke="#8DC63F" stroke-width="1.6"/><path d="M5.5 10.5C6 7.5 8 5.6 11 5.4C10.8 8.3 8.6 10.4 5.5 10.5Z" fill="#3AA14A"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 300 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: MariaDB data source (seal) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5 2.5C13 2.6 12.4 3.6 11.4 4.4C9.6 5.8 7.4 5.4 5.6 6.6C3.6 7.9 3 10.4 1.5 12.6C3.4 11.6 4.4 12.4 6.4 11.3C8.1 10.3 8.8 8.4 10.4 7.4C11.9 6.4 13.9 5.9 14.5 2.5Z" fill="#C0765A"/><circle cx="12.4" cy="4.5" r="0.6" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 391 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Neon data source -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="12" height="12" rx="3" fill="#00E599"/><path d="M5.5 11V5L10.5 11V7" stroke="#0C2B21" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 318 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: OceanBase data source (wave) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6.5" fill="#006AFF"/><path d="M3.5 9C5 7.2 6.5 7.2 8 9C9.5 10.8 11 10.8 12.5 9" stroke="#FFFFFF" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 317 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Amazon Redshift data source (columns) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 3.5L5 2.5V13.5L2 12.5V3.5Z" fill="#2E73B8"/><path d="M14 3.5L11 2.5V13.5L14 12.5V3.5Z" fill="#205B98"/><rect x="6" y="2" width="4" height="12" rx="0.5" fill="#5294CF"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 341 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: SingleStore data source -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 4.5A6 6 0 1 0 13.5 10" stroke="#AA00FF" stroke-width="1.8" stroke-linecap="round"/><circle cx="8" cy="8" r="2" fill="#AA00FF"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 287 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: Supabase data source (bolt) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 1.5L2.5 9.5H8L7 14.5L13.5 6.5H8L9 1.5Z" fill="#3ECF8E" stroke="#249361" stroke-width="0.8" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 279 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: TiDB data source (hexagon) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1.5L13.6 4.75V11.25L8 14.5L2.4 11.25V4.75L8 1.5Z" fill="#E30C34"/><path d="M5.5 6H10.5M8 6V11.5" stroke="#FFFFFF" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 319 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: TimescaleDB data source (tiger stripes) -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="12" height="12" rx="3" fill="#FDB515"/><path d="M5 2.5L7 8L5 13.5M9 2.5L11 8L9 13.5" stroke="#141E35" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 334 B |
@@ -0,0 +1,4 @@
|
||||
<!-- OpenIDEA: YugabyteDB data source -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 2.5L8 8.5L13 2.5" stroke="#FF6E42" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 8.5V13.5" stroke="#FF6E42" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 342 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 3V8.5C12.5 9.32843 11.8284 10 11 10H3" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M5.5 7.5L3 10L5.5 12.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 298 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 3V8.5C12.5 9.32843 11.8284 10 11 10H3" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M5.5 7.5L3 10L5.5 12.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 298 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="6" cy="6" r="3.5" stroke="#6C707E"/>
|
||||
<path d="M8.5 8.5L11.5 11.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M11 5H15L13 7.5L11 5Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 272 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="6" cy="6" r="3.5" stroke="#CED0D6"/>
|
||||
<path d="M8.5 8.5L11.5 11.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M11 5H15L13 7.5L11 5Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 272 B |