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: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ The full marketing site lives at **[scrapeman.app](https://scrapeman.app)** and
- Body sidecar: payloads >= 4KB auto-promoted to `files/<slug>.body.<ext>`
- Variable + collection tree lives in a user-chosen workspace folder — scrapeman never writes outside it
- Live file-watcher (chokidar) reloads external edits with self-write suppression
- **Per-request sync toggle**: right-click a request → "Stop syncing to git" to keep it local only. Backed by `.git/info/exclude` (never pushed) + `git rm --cached`, so teammates never see it. `⌘⇧H` toggles on the active tab. A crossed-eye icon marks unsynced requests in the sidebar and on the tab. If you later `git add` the file yourself, scrapeman notices and the icon clears automatically

### Local history
- Every sent request captured to a per-workspace JSONL file under app data dir (never the workspace)
Expand Down Expand Up @@ -141,7 +142,7 @@ The full marketing site lives at **[scrapeman.app](https://scrapeman.app)** and
- Resizable + orientable split (horizontal ↔ vertical, persistent)
- Resizable sidebar + history panel
- Dark mode with CSS variable tokens, system preference fallback
- Cross-platform keyboard shortcuts: ⌘T new tab, ⌘W close, ⌘↵ send, ⌘S save (with draft save-as flow)
- Cross-platform keyboard shortcuts: ⌘T new tab, ⌘W close, ⌘↵ send, ⌘S save (with draft save-as flow), ⌘⇧H toggle git-sync on active request
- Postman-light design system, Inter + Geist Mono fonts

## Install
Expand Down
142 changes: 141 additions & 1 deletion apps/desktop/src/main/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { execFile } from 'node:child_process';
import { promises as fsp } from 'node:fs';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
import { parsePorcelainStatus } from '@scrapeman/http-core';
import type {
Expand Down Expand Up @@ -240,6 +240,146 @@ export async function gitCommit(
await run(workspacePath, ['commit', '-m', message]);
}

// Local-only hide feature (issue #42): mark a request as ignored via
// .git/info/exclude so it stays on disk for the current user but is never
// synced via .gitignore (which itself would be tracked). Entries live in a
// marker block so we can list and unhide them cleanly without clobbering
// anything the user has in the rest of the file.
const EXCLUDE_BEGIN = '# >>> scrapeman:hidden (managed — do not edit)';
const EXCLUDE_END = '# <<< scrapeman:hidden';

async function gitDir(workspacePath: string): Promise<string> {
const { stdout } = await run(workspacePath, ['rev-parse', '--git-dir']);
const dir = stdout.trim();
return dir.startsWith('/') ? dir : join(workspacePath, dir);
}

async function readExcludeFile(
workspacePath: string,
): Promise<{ path: string; lines: string[] }> {
const path = join(await gitDir(workspacePath), 'info', 'exclude');
try {
const text = await fsp.readFile(path, 'utf8');
return { path, lines: text.split('\n') };
} catch {
return { path, lines: [] };
}
}

function parseHiddenBlock(lines: string[]): {
before: string[];
hidden: string[];
after: string[];
} {
const begin = lines.indexOf(EXCLUDE_BEGIN);
if (begin < 0) return { before: lines, hidden: [], after: [] };
const end = lines.indexOf(EXCLUDE_END, begin + 1);
if (end < 0) return { before: lines, hidden: [], after: [] };
const hidden: string[] = [];
for (let i = begin + 1; i < end; i += 1) {
const line = lines[i]!.trim();
if (!line || line.startsWith('#')) continue;
hidden.push(line.startsWith('/') ? line.slice(1) : line);
}
return {
before: lines.slice(0, begin),
hidden,
after: lines.slice(end + 1),
};
}

function serializeExcludeFile(
before: string[],
hidden: string[],
after: string[],
): string {
const pieces: string[] = [];
if (before.length > 0) pieces.push(before.join('\n').replace(/\n+$/, ''));
if (hidden.length > 0) {
const block = [EXCLUDE_BEGIN, ...hidden.map((p) => `/${p}`), EXCLUDE_END];
pieces.push(block.join('\n'));
}
if (after.length > 0) pieces.push(after.join('\n').replace(/^\n+/, ''));
return pieces.filter((p) => p.length > 0).join('\n\n') + '\n';
}

// "Whatever appears in git is unhidden": if any entry in our managed block
// is currently tracked by git (e.g. the user ran `git add` manually, or we
// failed to `rm --cached` earlier), drop it from the exclude file and
// report it as not-hidden. This makes hide/unhide self-consistent with the
// index — the only source of truth that matters for sync.
export async function gitLocalHiddenList(
workspacePath: string,
): Promise<string[]> {
if (!(await isInsideWorkTree(workspacePath))) return [];
const { path, lines } = await readExcludeFile(workspacePath);
const { before, hidden, after } = parseHiddenBlock(lines);
if (hidden.length === 0) return [];

const tracked = new Set<string>();
try {
const { stdout } = await run(workspacePath, [
'ls-files',
'-z',
'--',
...hidden,
]);
for (const entry of stdout.split('\0')) {
if (entry) tracked.add(entry);
}
} catch {
// ls-files shouldn't fail, but if it does treat nothing as tracked.
}

const stillHidden = hidden.filter((p) => !tracked.has(p));
if (stillHidden.length !== hidden.length) {
await fsp.writeFile(
path,
serializeExcludeFile(before, stillHidden, after),
'utf8',
);
}
return stillHidden;
}

export async function gitLocalHide(
workspacePath: string,
relPath: string,
): Promise<void> {
if (!(await isInsideWorkTree(workspacePath))) {
throw new GitError(
'Hiding requires a git repository. Run `git init` in this workspace first.',
'',
null,
);
}
const { path, lines } = await readExcludeFile(workspacePath);
const { before, hidden, after } = parseHiddenBlock(lines);
if (!hidden.includes(relPath)) hidden.push(relPath);
await fsp.mkdir(dirname(path), { recursive: true });
await fsp.writeFile(path, serializeExcludeFile(before, hidden, after), 'utf8');

// If the file is already tracked, remove it from the index so the hide
// actually takes effect. `--cached` leaves the working-tree file alone.
try {
await run(workspacePath, ['rm', '--cached', '--quiet', '--', relPath]);
} catch {
// Not tracked — nothing to do.
}
}

export async function gitLocalUnhide(
workspacePath: string,
relPath: string,
): Promise<void> {
if (!(await isInsideWorkTree(workspacePath))) return;
const { path, lines } = await readExcludeFile(workspacePath);
const { before, hidden, after } = parseHiddenBlock(lines);
const next = hidden.filter((p) => p !== relPath);
if (next.length === hidden.length) return;
await fsp.writeFile(path, serializeExcludeFile(before, next, after), 'utf8');
}

export async function gitPush(
workspacePath: string,
): Promise<{ ok: boolean; message?: string }> {
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import {
gitCommit,
gitPush,
gitPull,
gitLocalHiddenList,
gitLocalHide,
gitLocalUnhide,
GitError,
} from './git.js';

Expand Down Expand Up @@ -708,6 +711,33 @@ app.whenReady().then(() => {
ipcMain.handle('git:push', (_e, workspacePath: string) =>
gitPush(workspacePath),
);
ipcMain.handle('git:localHiddenList', async (_e, workspacePath: string) => {
try {
return await gitLocalHiddenList(workspacePath);
} catch (err) {
throw toGitError(err);
}
});
ipcMain.handle(
'git:localHide',
async (_e, workspacePath: string, relPath: string) => {
try {
await gitLocalHide(workspacePath, relPath);
} catch (err) {
throw toGitError(err);
}
},
);
ipcMain.handle(
'git:localUnhide',
async (_e, workspacePath: string, relPath: string) => {
try {
await gitLocalUnhide(workspacePath, relPath);
} catch (err) {
throw toGitError(err);
}
},
);
ipcMain.handle('git:pull', (_e, workspacePath: string) =>
gitPull(workspacePath),
);
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ const api: ScrapemanBridge = {
ipcRenderer.invoke('git:push', workspacePath) as Promise<GitOpResult>,
gitPull: (workspacePath: string) =>
ipcRenderer.invoke('git:pull', workspacePath) as Promise<GitOpResult>,
gitLocalHiddenList: (workspacePath: string) =>
ipcRenderer.invoke('git:localHiddenList', workspacePath) as Promise<string[]>,
gitLocalHide: (workspacePath: string, relPath: string) =>
ipcRenderer.invoke('git:localHide', workspacePath, relPath) as Promise<void>,
gitLocalUnhide: (workspacePath: string, relPath: string) =>
ipcRenderer.invoke('git:localUnhide', workspacePath, relPath) as Promise<void>,
};

contextBridge.exposeInMainWorld('scrapeman', api);
25 changes: 24 additions & 1 deletion apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export function App(): JSX.Element {
const send = useAppStore((s) => s.send);
const saveOrPrompt = useAppStore((s) => s.saveOrPrompt);
const focusUrl = useAppStore((s) => s.focusUrl);
const toggleHiddenRequest = useAppStore((s) => s.toggleHiddenRequest);
const tabs = useAppStore((s) => s.tabs);
const isRepo = useAppStore((s) => s.gitStatus?.isRepo === true);

useEffect(() => {
void loadRecents();
Expand All @@ -46,7 +49,10 @@ export function App(): JSX.Element {
useEffect(() => {
const unsubscribe = bridge.onWorkspaceEvent((event) => {
if (!workspace || event.workspacePath !== workspace.path) return;
if (event.type === 'tree-changed') void refreshTree();
if (event.type === 'tree-changed') {
void refreshTree();
void useAppStore.getState().loadHiddenRequests();
}
if (event.type === 'environments-changed') void loadEnvironments();
});
return unsubscribe;
Expand Down Expand Up @@ -84,6 +90,20 @@ export function App(): JSX.Element {
description: 'Reopen closed tab',
handler: () => reopenClosedTab(),
},
...(isRepo
? [
{
combo: 'mod+shift+h',
description: 'Toggle sync with git',
handler: (): void => {
const active = tabs.find((t) => t.id === activeTabId);
if (active?.kind === 'file' && active.relPath) {
void toggleHiddenRequest(active.relPath);
}
},
},
]
: []),
...([1, 2, 3, 4, 5, 6, 7, 8, 9] as const).map((n) => ({
combo: `mod+${n}` as const,
description: `Switch to tab ${n}`,
Expand All @@ -101,6 +121,9 @@ export function App(): JSX.Element {
send,
saveOrPrompt,
focusUrl,
toggleHiddenRequest,
tabs,
isRepo,
paletteOpen,
],
);
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/renderer/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export function useCommands(extras: CommandExtras): Command[] {
const focusUrl = useAppStore((s) => s.focusUrl);
const openImportCurl = useAppStore((s) => s.openImportCurl);
const openLoadTest = useAppStore((s) => s.openLoadTest);
const toggleHiddenRequest = useAppStore((s) => s.toggleHiddenRequest);
const tabs = useAppStore((s) => s.tabs);
const isRepo = useAppStore((s) => s.gitStatus?.isRepo === true);

return useMemo<Command[]>(
() => [
Expand Down Expand Up @@ -78,6 +81,22 @@ export function useCommands(extras: CommandExtras): Command[] {
if (activeTabId) duplicateTab(activeTabId);
},
},
...(isRepo
? [
{
id: 'request.toggle-hidden',
title: 'Toggle sync with git (on/off)',
section: 'Request',
shortcut: 'mod+shift+h',
run: () => {
const active = tabs.find((t) => t.id === activeTabId);
if (active?.kind === 'file' && active.relPath) {
void toggleHiddenRequest(active.relPath);
}
},
},
]
: []),
{
id: 'view.focus-url',
title: 'Focus URL bar',
Expand Down Expand Up @@ -108,6 +127,9 @@ export function useCommands(extras: CommandExtras): Command[] {
focusUrl,
openImportCurl,
openLoadTest,
toggleHiddenRequest,
tabs,
isRepo,
extras,
],
);
Expand Down
Loading
Loading