Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pins — there the chips are in the tab order, so Tab to one and press Enter or
leaving the keyboard.

- **Multi-token URLs** — drop values into any link, e.g. `https://tool.com/{ip}/{email}`
- **Web links only** — the resolved link must be `http` or `https`, so a template starts with `http://`, `https://` or the `{url}` token; the editor warns when it does not and offers a one-click fix
- **Open in bulk** — pin several values; the tray states how many tabs it will open before you click
- **Only what fits** — a tool is offered only when every token in its URL has a pinned value
- **Templates & sharing** — ready templates copy their filled-in text to the clipboard, and configs export / import for your team
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clipless",
"version": "2.2.3",
"version": "2.3.0",
"description": "A Clipboard manager for busy people",
"main": "./out/main/index.js",
"author": "Daniel Essig",
Expand Down
45 changes: 45 additions & 0 deletions src/renderer/src/components/settings/tools/ToolEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,51 @@ describe('ToolEditor', () => {
expect(screen.getByTestId('readiness')).toHaveTextContent('no tokens');
expect(screen.getByTestId('tool-preview-caption')).toHaveTextContent('Would open 1 tab');
});

it('warns beneath the URL field when the template has no http or https scheme and prefixes https:// on click', async () => {
await renderTools(
<ToolEditor
initial={{ name: 'Lookup', url: 'example.com/{email}' }}
onSave={onSave}
onCancel={onCancel}
/>
);
const warning = screen.getByTestId('tool-url-scheme');
expect(warning).toHaveTextContent('only http and https links can open');
expect(screen.getByTestId('tool-preview-caption')).toHaveTextContent('Would open 1 tab');
fireEvent.click(screen.getByTestId('tool-url-scheme-fix'));
expect(screen.getByTestId('tool-url')).toHaveValue('https://example.com/{email}');
expect(screen.queryByTestId('tool-url-scheme')).not.toBeInTheDocument();
fireEvent.change(screen.getByTestId('tool-url'), { target: { value: 'ftp://x/{email}' } });
expect(screen.getByTestId('tool-url-scheme')).toBeInTheDocument();
fireEvent.change(screen.getByTestId('tool-url'), { target: { value: 'HTTP://x/{email}' } });
expect(screen.queryByTestId('tool-url-scheme')).not.toBeInTheDocument();
fireEvent.change(screen.getByTestId('tool-url'), { target: { value: '' } });
expect(screen.queryByTestId('tool-url-scheme')).not.toBeInTheDocument();
});

it('does not warn when the template leads with the url token, which supplies its own scheme', async () => {
await renderTools(
<ToolEditor initial={{ name: 'Open', url: '{url}' }} onSave={onSave} onCancel={onCancel} />
);
expect(screen.queryByTestId('tool-url-scheme')).not.toBeInTheDocument();
expect(screen.queryByTestId('tool-url-scheme-fix')).not.toBeInTheDocument();
});

it('the https:// fix strips leading whitespace and leaves the caret after the prefix', async () => {
await renderTools(
<ToolEditor
initial={{ name: 'x', url: ' vt.example/{ip}' }}
onSave={onSave}
onCancel={onCancel}
/>
);
fireEvent.click(screen.getByTestId('tool-url-scheme-fix'));
const url = screen.getByTestId('tool-url') as HTMLInputElement;
expect(url).toHaveValue('https://vt.example/{ip}');
expect(url.selectionStart).toBe('https://'.length);
expect(document.activeElement).toBe(url);
});
});

describe('TemplateEditor', () => {
Expand Down
23 changes: 22 additions & 1 deletion src/renderer/src/components/settings/tools/ToolEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import { useEffect, useRef, useState } from 'react';
import { buildToolUrls } from '../../../../../shared/tools';
import { buildToolUrls, needsWebScheme, withWebScheme } from '../../../../../shared/tools';
import { Readiness } from './Readiness';
import { TokenPicker, insertAtCaret } from './TokenPicker';
import { TokenText } from './TokenText';
Expand Down Expand Up @@ -44,6 +44,7 @@ export function ToolEditor({ initial, onSave, onCancel }: ToolEditorProps) {
const count = buildToolUrls({ url }, values).length;
const resolved = resolveToolUrls(url, values);
const needed = groupsNeeded({ url });
const schemeless = needsWebScheme(url);

const save = async () => {
setSaving(true);
Expand Down Expand Up @@ -79,6 +80,11 @@ export function ToolEditor({ initial, onSave, onCancel }: ToolEditorProps) {
setCaret(next.caret);
};

const addScheme = () => {
setUrl(withWebScheme(url));
setCaret('https://'.length);
};

return (
<div className={styles.editor} onKeyDown={trapTab} data-testid="tool-editor">
<label className={w.field} htmlFor="tool-name">
Expand Down Expand Up @@ -107,6 +113,21 @@ export function ToolEditor({ initial, onSave, onCancel }: ToolEditorProps) {
onChange={(e) => setUrl(e.target.value)}
data-testid="tool-url"
/>
{schemeless && (
<div className={styles.meta}>
<span className={classNames(w.msg, w.msgWarn)} data-testid="tool-url-scheme">
only http and https links can open; this template starts with neither
</span>
<button
type="button"
className={w.link}
onClick={addScheme}
data-testid="tool-url-scheme-fix"
>
add https://
</button>
</div>
)}
<TokenPicker onInsert={insert} />
<div className={styles.meta}>
<Readiness item={{ url }} terms={config.terms} scan={scan} />
Expand Down
21 changes: 21 additions & 0 deletions src/renderer/src/components/tray/Tray.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,27 @@ describe('openTabs', () => {
errSpy.mockRestore();
});

it('names the cause when fewer tabs opened than were offered', async () => {
const toast = vi.fn();
const mock = window.api.openExternalUrls as unknown as ReturnType<typeof vi.fn>;
mock.mockResolvedValue(2);
await openTabs(['https://a', 'example.com/b', 'https://c'], toast);
expect(toast).toHaveBeenCalledWith('Opened 2 of 3 tabs; only http and https links can open', [
'https://a',
'example.com/b',
'https://c',
]);
mock.mockResolvedValue(0);
await openTabs(['example.com/b'], toast);
expect(toast).toHaveBeenLastCalledWith(
'Opened 0 of 1 tab; only http and https links can open',
['example.com/b']
);
mock.mockResolvedValue(1);
await openTabs(['https://a'], toast);
expect(toast).toHaveBeenLastCalledWith('Opened 1 tab', ['https://a']);
});

it('tabCount pluralises', () => {
expect(tabCount(1)).toBe('1 tab');
expect(tabCount(0)).toBe('0 tabs');
Expand Down
13 changes: 11 additions & 2 deletions src/renderer/src/components/tray/Tray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@ import styles from './Tray.module.css';

/**
* Open the tabs a tool or the tray produces through the main process, which accepts http
* and https only. Every control states its count before it is clicked (spec 6).
* and https only. Every control states its count before it is clicked (spec 6). When the
* main process dropped some, the toast says how many of the offered count opened and why,
* rather than reporting the smaller number as a plain success.
*/
export async function openTabs(urls: string[], toast: ToastFn): Promise<void> {
if (urls.length === 0) return;
try {
const opened = await window.api.openExternalUrls(urls);
toast(`Opened ${opened} ${opened === 1 ? 'tab' : 'tabs'}`, urls);
if (opened < urls.length) {
toast(
`Opened ${opened} of ${tabCount(urls.length)}; only http and https links can open`,
urls
);
} else {
toast(`Opened ${tabCount(opened)}`, urls);
}
} catch (error) {
console.error('Failed to open tabs:', error);
toast('Could not open the tabs', String(error));
Expand Down
75 changes: 74 additions & 1 deletion src/shared/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { toolTokens, toolReady, buildToolUrls } from './tools';
import {
toolTokens,
toolReady,
buildToolUrls,
hasWebScheme,
needsWebScheme,
withWebScheme,
} from './tools';

const tool = (url: string) => ({ url });

Expand Down Expand Up @@ -174,3 +181,69 @@ describe('buildToolUrls', () => {
expect(urls).toHaveLength(2);
});
});

describe('hasWebScheme', () => {
it('accepts http and https in any case, ignoring surrounding whitespace', () => {
expect(hasWebScheme('https://example.com/{email}')).toBe(true);
expect(hasWebScheme('http://example.com')).toBe(true);
expect(hasWebScheme(' HTTPS://example.com ')).toBe(true);
});

it('rejects a schemeless template and every other scheme', () => {
expect(hasWebScheme('example.com/{email}')).toBe(false);
expect(hasWebScheme('www.example.com')).toBe(false);
expect(hasWebScheme('ftp://example.com')).toBe(false);
expect(hasWebScheme('file:///etc/hosts')).toBe(false);
expect(hasWebScheme('javascript:alert(1)')).toBe(false);
expect(hasWebScheme('https:/example.com')).toBe(false);
expect(hasWebScheme('')).toBe(false);
});
});

describe('needsWebScheme', () => {
it.each([
['example.com/{email}'],
['www.example.com'],
['ftp://example.com'],
['https:/example.com'],
['{url|domain}'],
['{ip}/{url}'],
['x/{url}'],
['{URL}'],
])('warns on %s', (input) => {
expect(needsWebScheme(input)).toBe(true);
});

it.each([
[''],
[' '],
['https://example.com/{email}'],
[' HTTP://example.com'],
['{url}'],
[' { url }/extra'],
['{url|}'],
['{|url}'],
['{url|url}'],
])(
'does not warn on %s, which toolTokens reads as a leading url-only token or a web link',
(input) => {
expect(needsWebScheme(input)).toBe(false);
}
);
});

describe('withWebScheme', () => {
it.each([
['example.com/{email}', 'https://example.com/{email}'],
[' vt.example/{ip}', 'https://vt.example/{ip}'],
['ftp://vt.internal/{ip}', 'https://vt.internal/{ip}'],
['mailto:a@b.com', 'https://a@b.com'],
['file:///x/{ip}', 'https://x/{ip}'],
['https:/example.com/{ip}', 'https://example.com/{ip}'],
['HTTP://example.com', 'https://example.com'],
['localhost:3000/{ip}', 'https://localhost:3000/{ip}'],
])('%s -> %s', (input, expected) => {
expect(withWebScheme(input)).toBe(expected);
expect(hasWebScheme(withWebScheme(input))).toBe(true);
});
});
45 changes: 45 additions & 0 deletions src/shared/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,48 @@ export function buildToolUrls(tool: { url: string }, pins: PinsByGroup): string[

return urls.filter((url, index) => urls.indexOf(url) === index);
}

const WEB_SCHEME = /^https?:\/\//i;

/**
* Whether a tool URL template starts with http:// or https://. The main process opens
* nothing else (open-external.ts), so the tray explains when fewer tabs opened than it
* offered. The editor's warning is needsWebScheme, which also exempts a leading url token.
*/
export function hasWebScheme(url: string): boolean {
return WEB_SCHEME.test(url.trim());
}

/**
* Whether the editor should warn that this template will not open as a web link. A template
* that leads with a url-only token takes its scheme from the captured value (the built-in
* url pattern captures http:// or https://), which buildToolUrls substitutes unencoded, so
* it opens without a literal prefix. The token is read through toolTokens so {url|} and
* { url } count the same way there as here. An empty template gets no warning.
*/
export function needsWebScheme(url: string): boolean {
const trimmed = url.trim();
if (trimmed.length === 0 || hasWebScheme(trimmed)) return false;
const first = toolTokens(trimmed)[0];
const leadsWithUrl =
first !== undefined &&
trimmed.startsWith(first.token) &&
first.groups.length > 0 &&
first.groups.every((group) => group === 'url');
return !leadsWithUrl;
}

/**
* A leading scheme to drop before prefixing https://. The digit lookahead keeps a
* schemeless host:port ("localhost:3000/{ip}") intact, since no scheme is followed by a digit.
*/
const LEADING_SCHEME = /^[a-z][a-z0-9+.-]*:(?!\d)\/*/i;

/**
* The template rewritten to start with https://, replacing any leading scheme (ftp://,
* mailto:, file:///, the https:/ typo) and dropping leading whitespace. Kept beside
* hasWebScheme so the editor's one-click fix always produces something it accepts.
*/
export function withWebScheme(url: string): string {
return 'https://' + url.trimStart().replace(LEADING_SCHEME, '');
}
Loading