From 4d389e585bad709c6360d5600193af00e498a80f Mon Sep 17 00:00:00 2001 From: AbramovichCp Date: Mon, 17 Aug 2026 21:40:00 +0200 Subject: [PATCH 1/2] fix: preserve query string, credentials, and anchors in URL parsing --- .eslintrc.cjs | 2 +- README.md | 7 +- src/__tests__/structuredParam.test.ts | 10 + src/__tests__/urlParser.test.ts | 31 ++ src/__tests__/useAppStore.test.ts | 19 ++ src/components/AddParamRow/AddParamRow.tsx | 12 +- .../GroupSelector/GroupSelector.module.css | 29 ++ .../GroupSelector/GroupSelector.tsx | 96 +++++- src/components/JsonStack/JsonLeafInput.tsx | 6 +- src/components/JsonStack/JsonTree.tsx | 24 +- .../JsonStack/StackFrame.module.css | 16 +- src/components/JsonStack/StackFrame.tsx | 40 ++- .../ParamKeyInput/ParamKeyInput.module.css | 23 -- .../ParamKeyInput/ParamKeyInput.tsx | 48 --- src/components/ParamKeyInput/index.ts | 1 - src/components/ParamList/ParamList.tsx | 10 +- src/components/ParamRow/CopyButton.tsx | 51 ++++ src/components/ParamRow/ParamRow.tsx | 205 ++++++------- .../ParamTextInput.module.css} | 22 +- .../ParamTextInput.tsx} | 14 +- src/components/ParamTextInput/index.ts | 1 + src/components/ParamValueInput/index.ts | 1 - .../SavedLinksDrawer/GroupedLinksList.tsx | 117 +++++++ src/components/SavedLinksDrawer/SaveForm.tsx | 74 +++++ .../SavedLinksDrawer/SavedLinksDrawer.tsx | 285 ++++-------------- src/components/UrlPreview/UrlPreview.tsx | 10 + src/components/icons.tsx | 17 +- src/hooks/useFocusTrap.ts | 12 +- src/hooks/useKeyboardShortcuts.ts | 10 +- src/hooks/useSavedLinks.ts | 21 +- src/hooks/useTheme.ts | 12 +- src/lib/dom.ts | 12 + src/lib/id.ts | 10 + src/lib/paramTypes.ts | 22 +- src/lib/structuredParam.ts | 28 +- src/lib/urlParser.ts | 73 ++--- src/popup/App.tsx | 63 ++-- src/store/useAppStore.ts | 9 +- src/types/index.ts | 7 +- vite.config.ts | 3 +- 40 files changed, 856 insertions(+), 597 deletions(-) delete mode 100644 src/components/ParamKeyInput/ParamKeyInput.module.css delete mode 100644 src/components/ParamKeyInput/ParamKeyInput.tsx delete mode 100644 src/components/ParamKeyInput/index.ts create mode 100644 src/components/ParamRow/CopyButton.tsx rename src/components/{ParamValueInput/ParamValueInput.module.css => ParamTextInput/ParamTextInput.module.css} (68%) rename src/components/{ParamValueInput/ParamValueInput.tsx => ParamTextInput/ParamTextInput.tsx} (68%) create mode 100644 src/components/ParamTextInput/index.ts delete mode 100644 src/components/ParamValueInput/index.ts create mode 100644 src/components/SavedLinksDrawer/GroupedLinksList.tsx create mode 100644 src/components/SavedLinksDrawer/SaveForm.tsx create mode 100644 src/lib/dom.ts create mode 100644 src/lib/id.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d4762b6..0102ecb 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -10,7 +10,7 @@ module.exports = { ], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - plugins: ['react-refresh', 'jsx-a11y'], + plugins: ['jsx-a11y'], settings: { react: { version: '18.3' } }, rules: { 'react/react-in-jsx-scope': 'off', diff --git a/README.md b/README.md index 650af38..5769b0d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A Chrome extension for inspecting, editing, saving, and replaying URL query para | `Enter` in a row field | Commit / advance focus | | `Cmd`/`Ctrl` + `Enter` | Apply edited URL to the active tab | | `Cmd`/`Ctrl` + `S` | Open the saved URLs drawer | -| `Esc` | Close the drawer | +| `Esc` | Close the drawer / step one level out of an expanded JSON value | | `Space` on a `BOOL` toggle | Flip the value | | `←` / `→` on the theme switch | Cycle light → system → dark | @@ -39,7 +39,7 @@ A Chrome extension for inspecting, editing, saving, and replaying URL query para - **Vite** + `@crxjs/vite-plugin` for HMR during popup development - **Zustand** for state (small footprint, test-friendly) - **CSS Modules** + CSS custom properties for theming -- **Vitest** + React Testing Library (infrastructure ready; tests not yet written) +- **Vitest** + React Testing Library (unit tests cover URL parsing, param typing, structured values, storage, and the store) ## Permissions @@ -47,8 +47,7 @@ The extension requests only what it needs: | Permission | Why | | --- | --- | -| `activeTab` | Read and update the URL of the currently focused tab | -| `tabs` | Query the active tab's URL when the popup opens | +| `activeTab` | Read and update the URL of the currently focused tab (granted per user gesture — no standing access to browsing history) | | `storage` | Persist saved links, groups, and theme preference locally | No ``, no `clipboardWrite` (popups are secure contexts and a user-gesture click is enough for `navigator.clipboard.writeText`). diff --git a/src/__tests__/structuredParam.test.ts b/src/__tests__/structuredParam.test.ts index 5c0aa2e..27e7e8c 100644 --- a/src/__tests__/structuredParam.test.ts +++ b/src/__tests__/structuredParam.test.ts @@ -214,4 +214,14 @@ describe('renameKeyAtPath', () => { renameKeyAtPath(root, [], 'a', 'b'); expect(root).toEqual({ a: 1 }); }); + + it('refuses to rename onto an existing key (would lose a value)', () => { + const root = { a: 1, b: 2 }; + expect(renameKeyAtPath(root, [], 'a', 'b')).toBe(root); + }); + + it('refuses collision at a nested path', () => { + const root = { nested: { x: 10, y: 20 } }; + expect(renameKeyAtPath(root, ['nested'], 'x', 'y')).toBe(root); + }); }); diff --git a/src/__tests__/urlParser.test.ts b/src/__tests__/urlParser.test.ts index 8dbd41f..4768c34 100644 --- a/src/__tests__/urlParser.test.ts +++ b/src/__tests__/urlParser.test.ts @@ -91,6 +91,37 @@ describe('parseUrl', () => { const result = parseUrl('https://example.com/?q=hello%20world'); expect(result.params[0].value).toBe('hello world'); }); + + it('keeps the real query string when a hash query is also present', () => { + const result = parseUrl('https://app.com/path?main=1#/route?foo=bar'); + expect(result.hashQuery).toBe(true); + expect(result.base).toBe('https://app.com/path?main=1'); + expect(result.params).toHaveLength(1); + expect(result.params[0]).toMatchObject({ key: 'foo', value: 'bar' }); + // Round trip must not drop ?main=1. + expect(serializeUrl(result)).toBe('https://app.com/path?main=1#/route?foo=bar'); + expect(serializeUrlForNav(result)).toBe('https://app.com/path?main=1#/route?foo=bar'); + }); + + it('does not treat an anchor ending in "?" as a hash query', () => { + const result = parseUrl('https://en.wikipedia.org/wiki/Foo#Why?'); + expect(result.hashQuery).toBe(false); + expect(result.fragment).toBe('#Why?'); + expect(serializeUrl(result)).toBe('https://en.wikipedia.org/wiki/Foo#Why?'); + }); + + it('builds a correct base for file: URLs', () => { + const result = parseUrl('file:///tmp/page.html?x=1'); + expect(result.base).toBe('file:///tmp/page.html'); + expect(result.params[0]).toMatchObject({ key: 'x', value: '1' }); + expect(serializeUrl(result)).toBe('file:///tmp/page.html?x=1'); + }); + + it('preserves credentials and port in the base', () => { + const result = parseUrl('https://user:pass@example.com:8080/x?a=1'); + expect(result.base).toBe('https://user:pass@example.com:8080/x'); + expect(serializeUrl(result)).toBe('https://user:pass@example.com:8080/x?a=1'); + }); }); describe('serializeUrl', () => { diff --git a/src/__tests__/useAppStore.test.ts b/src/__tests__/useAppStore.test.ts index 5449976..07c9e1e 100644 --- a/src/__tests__/useAppStore.test.ts +++ b/src/__tests__/useAppStore.test.ts @@ -79,6 +79,25 @@ describe('setCurrentUrl', () => { useAppStore.getState().setCurrentUrl('totally invalid'); expect(useAppStore.getState().currentParsed).toEqual(before); }); + + it('keeps param ids stable across re-parses when position+key survive', () => { + useAppStore.getState().loadUrl('https://example.com/?a=1&b=2', 1); + const [a, b] = useAppStore.getState().currentParsed!.params; + // Simulate typing in the URL field: value of `a` changed, keys unchanged. + useAppStore.getState().setCurrentUrl('https://example.com/?a=99&b=2'); + const after = useAppStore.getState().currentParsed!.params; + expect(after[0].id).toBe(a.id); + expect(after[0].value).toBe('99'); + expect(after[1].id).toBe(b.id); + }); + + it('assigns a fresh id when the key at a position changes', () => { + useAppStore.getState().loadUrl('https://example.com/?a=1', 1); + const [a] = useAppStore.getState().currentParsed!.params; + useAppStore.getState().setCurrentUrl('https://example.com/?renamed=1'); + const after = useAppStore.getState().currentParsed!.params; + expect(after[0].id).not.toBe(a.id); + }); }); describe('updateParamKey', () => { diff --git a/src/components/AddParamRow/AddParamRow.tsx b/src/components/AddParamRow/AddParamRow.tsx index d3a846c..72725e5 100644 --- a/src/components/AddParamRow/AddParamRow.tsx +++ b/src/components/AddParamRow/AddParamRow.tsx @@ -1,8 +1,7 @@ import { useId, useRef, useState } from 'react'; import { IconButton } from '../IconButton'; import { IconPlus } from '../icons'; -import { ParamKeyInput } from '../ParamKeyInput'; -import { ParamValueInput } from '../ParamValueInput'; +import { ParamTextInput } from '../ParamTextInput'; import styles from './AddParamRow.module.css'; interface AddParamRowProps { @@ -28,7 +27,8 @@ export function AddParamRow({ onAdd }: AddParamRowProps) { function commit() { if (!canAdd) return; - onAdd(key, value); + // Trim the key: stray spaces would silently become %20 in the URL. + onAdd(key.trim(), value); setKey(''); setValue(''); // Return focus to key field for fast repeated additions. @@ -42,8 +42,9 @@ export function AddParamRow({ onAdd }: AddParamRowProps) { - New parameter value - void; - onCreateNew: () => void; + /** Called with the trimmed name when the user confirms the inline "new group" form. */ + onCreateNew: (name: string) => void; label?: string; } @@ -16,6 +18,10 @@ const CREATE_NEW_VALUE = '__create_new__'; * A native - - {groups.map((g) => ( - - ))} - + + {creating ? ( +
+ setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + confirmCreate(); + } + if (e.key === 'Escape') { + // Cancel only the inline form — stop the event so the drawer + // (focus trap listens on document) doesn't close too. + e.preventDefault(); + e.stopPropagation(); + cancelCreate(); + } + }} + /> + + +
+ ) : ( + + )} ); } diff --git a/src/components/JsonStack/JsonLeafInput.tsx b/src/components/JsonStack/JsonLeafInput.tsx index 60feabc..06263a4 100644 --- a/src/components/JsonStack/JsonLeafInput.tsx +++ b/src/components/JsonStack/JsonLeafInput.tsx @@ -29,7 +29,10 @@ export function JsonLeafInput({ value, onCommit }: JsonLeafInputProps) { const inputRef = useRef(null); useEffect(() => { - if (editing) inputRef.current?.select(); + if (editing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } }, [editing]); function startEdit() { @@ -52,7 +55,6 @@ export function JsonLeafInput({ value, onCommit }: JsonLeafInputProps) { ref={inputRef} className={styles.input} value={draft} - autoFocus style={{ width: Math.max(60, draft.length * 8 + 16) }} onChange={(e) => setDraft(e.target.value)} onBlur={commit} diff --git a/src/components/JsonStack/JsonTree.tsx b/src/components/JsonStack/JsonTree.tsx index f7482f0..84302b0 100644 --- a/src/components/JsonStack/JsonTree.tsx +++ b/src/components/JsonStack/JsonTree.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { isContainer, shortPreview } from '@/lib/structuredParam'; +import { IconChevronRightSmall } from '../icons'; import { JsonLeafInput } from './JsonLeafInput'; import styles from './JsonTree.module.css'; @@ -13,13 +14,18 @@ interface JsonTreeProps { function EditableKey({ k, onCommit }: { k: string; onCommit: (newKey: string) => void }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(k); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.focus(); + }, [editing]); if (editing) { return ( setDraft(e.target.value)} onBlur={() => { setEditing(false); @@ -60,7 +66,7 @@ function EditableKey({ k, onCommit }: { k: string; onCommit: (newKey: string) => } }} > - "{k}" + {`"${k}"`} ); } @@ -107,22 +113,14 @@ export function JsonTree({ value, onPush, onLeafChange, onKeyChange }: JsonTreeP ) : isArr ? ( {k} ) : ( - "{k}" + {`"${k}"`} )} {' : '} {container ? ( <> {shortPreview(v)} - - - + ) : ( diff --git a/src/components/JsonStack/StackFrame.module.css b/src/components/JsonStack/StackFrame.module.css index 97db796..7e6f2fc 100644 --- a/src/components/JsonStack/StackFrame.module.css +++ b/src/components/JsonStack/StackFrame.module.css @@ -37,7 +37,7 @@ } .crumbRoot { - color: #9CA3AF; + color: var(--color-text-muted); cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); @@ -57,12 +57,12 @@ } .crumbSep { - color: #4B5563; + color: var(--color-text-muted); user-select: none; } .crumbAncestor { - color: #9CA3AF; + color: var(--color-text-muted); cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); @@ -76,7 +76,7 @@ } .crumbCurrent { - color: #7DD3FC; + color: var(--json-key); font-weight: 600; padding: 2px 4px; } @@ -119,6 +119,14 @@ overflow-y: auto; } +/* Shown when the drilled-into param disappeared or stopped being valid JSON */ +.missing { + margin: 0; + padding: var(--space-4) 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + /* ── Footer / Back button ── */ .footer { diff --git a/src/components/JsonStack/StackFrame.tsx b/src/components/JsonStack/StackFrame.tsx index a2206fa..7c34b2d 100644 --- a/src/components/JsonStack/StackFrame.tsx +++ b/src/components/JsonStack/StackFrame.tsx @@ -1,6 +1,6 @@ import type { QueryParam } from '@/types'; import { - parseStructuredValue, + tryParseStructured, serializeStructuredValue, getAtPath, setAtPath, @@ -41,14 +41,28 @@ export function StackFrame({ }: StackFrameProps) { const currentFrame = frames[frames.length - 1]; const param = params.find((p) => p.id === currentFrame.paramId); + const rootValue = param === undefined ? undefined : tryParseStructured(param.value); - if (!param) return null; - - let rootValue: unknown; - try { - rootValue = parseStructuredValue(param.value); - } catch { - return null; + // The param was removed or its value stopped being valid JSON while the + // stack was open — offer a way back instead of a dead blank panel. + if (rootValue === undefined) { + return ( +
+
+

This value is no longer available.

+
+
+ +
+
+ ); } const frameValue = getAtPath(rootValue, currentFrame.path); @@ -82,7 +96,7 @@ export function StackFrame({ {/* Breadcrumb + Structured/Raw toggle */}