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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Run linting
run: npm run lint

- name: Run tests
run: npm test

- name: Check version bump
run: |
# Get current version from package.json
Expand Down
153 changes: 153 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
name: Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
unit-tests:
runs-on: ubuntu-latest

steps:
- name: Check out Git repository
uses: actions/checkout@v4

- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npx vitest run --coverage --coverage.reporter=json-summary

- name: Post coverage comment
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SUMMARY=$(cat coverage/coverage-summary.json)
STMTS=$(echo "$SUMMARY" | jq -r '.total.statements.pct')
BRANCH=$(echo "$SUMMARY" | jq -r '.total.branches.pct')
FUNCS=$(echo "$SUMMARY" | jq -r '.total.functions.pct')
LINES=$(echo "$SUMMARY" | jq -r '.total.lines.pct')

CONTENT=$(cat <<EOF
## 📊 Coverage Report

| Metric | Coverage |
|--------|----------|
| Statements | ${STMTS}% |
| Branches | ${BRANCH}% |
| Functions | ${FUNCS}% |
| Lines | ${LINES}% |
EOF
)

MARKER="<!-- coverage-report -->"
BODY="$MARKER
$CONTENT"
REPO="${{ github.repository }}"
PR_NUMBER="${{ github.event.pull_request.number }}"

COMMENT_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null | head -1 | tr -d '[:space:]')
if [ -n "$COMMENT_ID" ]; then
gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY"
else
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -f body="$BODY"
fi

e2e-tests:
needs: unit-tests
runs-on: windows-latest

steps:
- name: Check out Git repository
uses: actions/checkout@v4

- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build application
run: npm run build

- name: Run E2E tests
id: e2e
shell: bash
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: e2e-results.json
run: npx playwright test --reporter=json,list
continue-on-error: true

- name: Verify E2E tests actually ran
shell: bash
run: |
if [ ! -f e2e-results.json ]; then
echo "::error::E2E results file was not created — Playwright may not have run"
exit 1
fi
TOTAL=$(jq '[.. | objects | select(has("tests")) | .tests[]] | length' e2e-results.json 2>/dev/null || echo "0")
if [ "$TOTAL" -eq 0 ]; then
echo "::error::E2E results contain no test specs — tests did not actually execute"
cat e2e-results.json
exit 1
fi
echo "E2E results validated: $TOTAL test(s) found"

- name: Upload Playwright traces on failure
if: steps.e2e.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: test-results/
retention-days: 7

- name: Post E2E comment
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
if [ ! -f e2e-results.json ]; then
CONTENT="## 🧪 E2E Test Results\n\n❌ **Tests failed to produce results**"
else
CONTENT=$(jq -r '
.stats as $s |
(if $s.unexpected == 0 then "✅" else "❌" end) as $status |
[.suites[].suites[] | {title, ok: (.specs | all(.ok))}] as $suites |
"## 🧪 E2E Test Results\n\n" +
$status + " **" + ($s.expected | tostring) + " passed**, " +
($s.unexpected | tostring) + " failed, " +
($s.skipped | tostring) + " skipped\n\n" +
"| Test Suite | Result |\n|------------|--------|\n" +
($suites | map("| " + .title + " | " + (if .ok then "✅" else "❌" end) + " |") | join("\n"))
' e2e-results.json)
fi

MARKER="<!-- e2e-report -->"
BODY="$MARKER
$CONTENT"
REPO="${{ github.repository }}"
PR_NUMBER="${{ github.event.pull_request.number }}"

COMMENT_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null | head -1 | tr -d '[:space:]')
if [ -n "$COMMENT_ID" ]; then
gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY"
else
gh api "repos/$REPO/issues/$PR_NUMBER/comments" -f body="$BODY"
fi

- name: Fail if E2E tests failed
if: steps.e2e.outcome == 'failure'
run: exit 1
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ out
electron.vite.config.*.mjs
.cursor/rules/user
.claude/
test-results/
coverage/
41 changes: 40 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,42 @@ Clipless is an Electron clipboard manager built with React and TypeScript. It mo
- `npm run typecheck` — Type check all TypeScript (runs both `typecheck:node` for main/preload and `typecheck:web` for renderer)
- `npm run build:win` / `build:mac` / `build:linux` — Platform-specific packaging

No test framework is configured.
### Testing

- `npm test` / `npx vitest` — Unit tests (Vitest)
- `npx playwright test` — E2E tests (Playwright with Electron)

**Note:** E2E tests interact with the **system clipboard**. Running them will read from and write to your actual OS clipboard. Avoid copying sensitive data before running e2e tests, and expect your clipboard contents to be overwritten.

## Verification

After making any code changes, always run the following before considering work complete:

1. **Lint and typecheck** — must produce zero errors and zero warnings:
```bash
npm run lint && npm run typecheck
```

2. **Unit tests with coverage** — must maintain 100% code coverage across statements, branches, functions, and lines:
```bash
npx vitest run --coverage
```

3. **E2E tests** — must all pass:
```bash
npx playwright test
```

Fix all failures before moving on. Do not leave broken lint, type errors, coverage gaps, or failing tests for later.

## Architecture

Electron three-process architecture using `electron-vite` as the build system and Tailwind CSS v4 for styling.

### Main Process (`src/main/`)

Node.js process handling system integration. Key modules:

- **`clipboard/`** — 250ms polling-based clipboard monitoring, Quick Clips pattern scanning, Quick Tools URL generation, templates
- **`storage/`** — `SecureStorage` singleton using Electron's `safeStorage` (OS-native encryption: DPAPI/Keychain/Secret Service). Data stored as `data.enc`
- **`hotkeys/`** — Global hotkey registration via `globalShortcut` with modular registry/actions/manager pattern
Expand All @@ -31,22 +59,27 @@ Node.js process handling system integration. Key modules:
- **`updater/`** — Auto-updates via electron-updater from GitHub releases

### Preload (`src/preload/`)

Context bridge exposing typed IPC APIs to renderer. All renderer↔main communication goes through `api.*` methods defined here. IPC channels are organized by domain: clipboard, settings, storage, templates, search-terms, quick-tools.

### Renderer (`src/renderer/`)

React 19 app with three entry points (`main.tsx`, `settings-main.tsx`, `tools-launcher-main.tsx`) and corresponding HTML files.

State management uses React Context providers:

- **`providers/clips/`** — Clipboard state with hooks: `useClipsStorage`, `useClipboardOperations`, `useClipState`. Handles clip lifecycle, deduplication, and locking
- **`providers/theme.tsx`** — Light/dark theme with system detection
- **`providers/languageDetection.tsx`** — Code language detection toggle

Clip types are rendered by type-specific components in `components/clips/clip/` (TextClip, HtmlClip, ImageClip, RtfClip, BookmarkClip).

### Shared (`src/shared/`)

TypeScript interfaces and constants used by all processes.

### Data Flow

User copies → main process detects via polling → reads clipboard → sends `clipboard-changed` IPC event → renderer updates state via ClipsProvider → saves back to encrypted storage via IPC.

## Linear Ticket Template
Expand All @@ -57,21 +90,27 @@ When creating Linear tickets for this project, use team **Clipless** and the fol
**Priority:** 1=Urgent, 2=High, 3=Normal, 4=Low

### Title

Short imperative description (e.g. "Add keyboard shortcut for clearing clips")

### Description format

```markdown
## Summary

One or two sentences describing what needs to happen and why.

## Context

- What currently happens (for bugs) or what's missing (for features)
- Any relevant user workflow or affected area (clipboard, storage, hotkeys, settings, etc.)

## Acceptance Criteria

- [ ] Specific, verifiable condition
- [ ] Another condition

## Affected Areas

Which modules are likely involved: clipboard/, storage/, hotkeys/, window/, renderer components, preload API, shared types.
```
4 changes: 3 additions & 1 deletion docs/TOOLS_LAUNCHER_HOTKEY_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### Test Steps:

1. **Enable Hotkeys**:
1. **Enable Hotkeys**:
- Open Clipless Settings (gear icon or Ctrl+Shift+V)
- Go to "Hotkeys" tab
- Ensure "Enable Global Hotkeys" is checked
Expand All @@ -29,13 +29,15 @@
- You should be able to select patterns and tools to launch

### Expected Behavior:

- Hotkey works even when main window is hidden/minimized
- Opens tools launcher with the first (most recent) clip content
- Automatically scans for patterns and displays results
- Window can be closed with Esc key or close button
- After launching tools, window closes automatically

### Configuration:

- Default hotkey: `CommandOrControl+Shift+T`
- Can be customized in Settings > Hotkeys
- Can be enabled/disabled individually
10 changes: 10 additions & 0 deletions docs/TOOLS_LAUNCHER_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,56 @@ The QuickClipsScanner functionality has been extracted from the main window into
## Changes Made

### 1. Configuration Updates

- **electron.vite.config.ts**: Added `tools-launcher` entry point to build configuration
- **HTML File**: Created `src/renderer/tools-launcher.html` for the tools launcher window
- **Entry Point**: Created `src/renderer/src/tools-launcher-main.tsx` to bootstrap the launcher

### 2. New Components

- **ToolsLauncher**: New React component that wraps the QuickClipsScanner in a window context
- **Updated QuickClipsScanner**: Modified to work both as a modal (legacy) and standalone window

### 3. Window Management

- **createToolsLauncherWindow()**: New function in `src/main/window/creation.ts`
- **Window positioning**: Similar to settings window, positioned relative to main window
- **Window properties**: 1000x700px, non-resizable, modal parent relationship

### 4. IPC Integration

- **open-tools-launcher**: Opens tools launcher window with clip content
- **close-tools-launcher**: Closes the tools launcher window
- **tools-launcher-ready**: Signals when window is ready for data
- **tools-launcher-initialize**: Sends clip content to window
- **onToolsLauncherInitialize**: Listener for initialization data

### 5. CSS Updates

- **Standalone Mode**: Added `.standalone` style for full-window display
- **Removed Overlay**: No overlay needed when used as dedicated window

### 6. Hotkey Integration

- **New Hotkey**: `CommandOrControl+Shift+T` to open tools launcher for the first (most recent) clip
- **Global Access**: Works even when main window is hidden/minimized
- **Settings Integration**: Configurable in Settings > Hotkeys section

## Usage

### Via Scan Button

When users click the scan button (🔍) on any clip:

1. A new tools-launcher window opens with the clip content
2. The window automatically scans for patterns and displays results
3. Users can select patterns and tools, then launch them
4. Window closes automatically after tools are launched or when cancelled

### Via Hotkey

Users can press `Ctrl+Shift+T` (or `Cmd+Shift+T` on Mac):

1. Opens tools launcher with the most recent (first) clip content
2. Works even when the main window is hidden or minimized
3. Provides quick access to tools without navigating the UI
Expand Down
42 changes: 42 additions & 0 deletions e2e/app-launch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test, expect, _electron as electron } from '@playwright/test';
import { resolve } from 'path';

test.describe('App Launch', () => {
test('app launches and main window is visible', async () => {
const app = await electron.launch({
args: [resolve(__dirname, '../out/main/index.js')],
});

const window = await app.firstWindow();
await window.waitForSelector('#root > *');
expect(window).toBeTruthy();

const title = await window.title();
expect(title).toBeTruthy();

const isVisible = await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return win ? win.isVisible() : false;
});
expect(isVisible).toBe(true);

await app.close();
});

test('app window has expected dimensions', async () => {
const app = await electron.launch({
args: [resolve(__dirname, '../out/main/index.js')],
});

const window = await app.firstWindow();
const { width, height } = await window.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));

expect(width).toBeGreaterThan(0);
expect(height).toBeGreaterThan(0);

await app.close();
});
});
Loading