add new style and global search

This commit is contained in:
Christoph Brandau
2026-06-29 17:24:08 +02:00
parent 9861fa2446
commit b0491d1479
17 changed files with 1805 additions and 70 deletions
+10
View File
@@ -11,6 +11,7 @@
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"simple-icons": "^16.24.1", "simple-icons": "^16.24.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"tailwindcss": "^4.3.1" "tailwindcss": "^4.3.1"
@@ -1427,6 +1428,15 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"simple-icons": "^16.24.1", "simple-icons": "^16.24.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"tailwindcss": "^4.3.1" "tailwindcss": "^4.3.1"
+326
View File
@@ -0,0 +1,326 @@
import { Buffer } from "node:buffer";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const iconsDir = join(root, "src-tauri", "icons");
const icoSizes = [16, 24, 32, 48, 64, 128, 256];
function clamp(value, min = 0, max = 1) {
return Math.min(max, Math.max(min, value));
}
function smoothstep(edge0, edge1, value) {
const t = clamp((value - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
}
function hex(value) {
const clean = value.replace("#", "");
return {
r: Number.parseInt(clean.slice(0, 2), 16),
g: Number.parseInt(clean.slice(2, 4), 16),
b: Number.parseInt(clean.slice(4, 6), 16),
};
}
function mix(a, b, t) {
const u = clamp(t);
return {
r: a.r + (b.r - a.r) * u,
g: a.g + (b.g - a.g) * u,
b: a.b + (b.b - a.b) * u,
};
}
function addGlow(color, glow, amount) {
return {
r: clamp(color.r + glow.r * amount, 0, 255),
g: clamp(color.g + glow.g * amount, 0, 255),
b: clamp(color.b + glow.b * amount, 0, 255),
};
}
function blend(base, layer) {
const alpha = clamp(layer.a);
const outA = alpha + base.a * (1 - alpha);
if (outA <= 0) return { r: 0, g: 0, b: 0, a: 0 };
return {
r: (layer.r * alpha + base.r * base.a * (1 - alpha)) / outA,
g: (layer.g * alpha + base.g * base.a * (1 - alpha)) / outA,
b: (layer.b * alpha + base.b * base.a * (1 - alpha)) / outA,
a: outA,
};
}
function roundedRectDistance(x, y, cx, cy, hx, hy, radius) {
const qx = Math.abs(x - cx) - hx + radius;
const qy = Math.abs(y - cy) - hy + radius;
const outsideX = Math.max(qx, 0);
const outsideY = Math.max(qy, 0);
return Math.hypot(outsideX, outsideY) + Math.min(Math.max(qx, qy), 0) - radius;
}
function segmentDistance(px, py, ax, ay, bx, by) {
const vx = bx - ax;
const vy = by - ay;
const wx = px - ax;
const wy = py - ay;
const lenSq = vx * vx + vy * vy;
const t = lenSq === 0 ? 0 : clamp((wx * vx + wy * vy) / lenSq);
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
}
function polygonContains(x, y, points) {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i][0];
const yi = points[i][1];
const xj = points[j][0];
const yj = points[j][1];
const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersects) inside = !inside;
}
return inside;
}
function polygonDistance(x, y, points) {
let distance = Infinity;
for (let i = 0; i < points.length; i += 1) {
const a = points[i];
const b = points[(i + 1) % points.length];
distance = Math.min(distance, segmentDistance(x, y, a[0], a[1], b[0], b[1]));
}
return polygonContains(x, y, points) ? -distance : distance;
}
function drawStroke(color, x, y, ax, ay, bx, by, radius, strokeColor, alpha = 1) {
const d = segmentDistance(x, y, ax, ay, bx, by);
const a = smoothstep(radius + 1.4, radius - 0.8, d) * alpha;
if (a <= 0) return color;
return blend(color, { ...strokeColor, a });
}
function drawCircle(color, x, y, cx, cy, radius, circleColor, alpha = 1) {
const d = Math.hypot(x - cx, y - cy);
const a = smoothstep(radius + 1.4, radius - 0.8, d) * alpha;
if (a <= 0) return color;
return blend(color, { ...circleColor, a });
}
function sampleScene(x, y) {
const dark = hex("#10111f");
const purple = hex("#bd34fe");
const indigo = hex("#646cff");
const cyan = hex("#41d1ff");
const yellow = hex("#ffd343");
const white = hex("#f7fbff");
let color = { r: 0, g: 0, b: 0, a: 0 };
const badge = roundedRectDistance(x, y, 128, 128, 108, 108, 48);
const shadowA = Math.exp(-Math.max(badge, 0) / 12) * 0.22;
if (badge > 0 && shadowA > 0.005) {
color = blend(color, { r: 0, g: 0, b: 0, a: shadowA });
}
const badgeA = smoothstep(1.6, -1.2, badge);
if (badgeA > 0) {
const diagonal = (x + y) / 512;
let base = mix(dark, indigo, diagonal * 0.75);
const purpleGlow = Math.exp(-((x - 68) ** 2 + (y - 62) ** 2) / 6200);
const cyanGlow = Math.exp(-((x - 184) ** 2 + (y - 72) ** 2) / 5600);
const yellowGlow = Math.exp(-((x - 184) ** 2 + (y - 202) ** 2) / 8400);
base = addGlow(base, purple, purpleGlow * 0.58);
base = addGlow(base, cyan, cyanGlow * 0.55);
base = addGlow(base, yellow, yellowGlow * 0.22);
color = blend(color, { ...base, a: badgeA });
const rim = smoothstep(4.2, 0.4, Math.abs(badge));
color = blend(color, { ...white, a: rim * 0.18 * badgeA });
}
const bolt = [
[144, 40],
[198, 40],
[164, 111],
[207, 111],
[113, 218],
[143, 142],
[103, 142],
];
const boltD = polygonDistance(x, y, bolt);
const boltA = smoothstep(2.5, -1, boltD);
if (boltA > 0) {
color = blend(color, { ...yellow, a: boltA * 0.24 });
}
const glow = hex("#41d1ff");
const line = hex("#eef7ff");
const nodeFill = hex("#131827");
for (const segment of [
[82, 73, 82, 181],
[82, 116, 166, 116],
]) {
color = drawStroke(color, x, y, ...segment, 12, glow, 0.24);
color = drawStroke(color, x, y, ...segment, 5.2, line, 0.94);
}
for (const [cx, cy, accent] of [
[82, 73, cyan],
[166, 116, purple],
[82, 181, yellow],
]) {
color = drawCircle(color, x, y, cx, cy, 22, accent, 0.25);
color = drawCircle(color, x, y, cx, cy, 14.5, white, 0.96);
color = drawCircle(color, x, y, cx, cy, 8.4, nodeFill, 1);
color = drawCircle(color, x, y, cx, cy, 4.2, accent, 0.95);
}
const spark = [
[186, 161],
[195, 181],
[215, 190],
[195, 199],
[186, 219],
[177, 199],
[157, 190],
[177, 181],
];
const sparkD = polygonDistance(x, y, spark);
const sparkA = smoothstep(1.8, -0.8, sparkD);
if (sparkA > 0) {
color = blend(color, { ...yellow, a: sparkA * 0.92 });
}
return color;
}
function renderPng(size) {
const scale = 256 / size;
const samples = size <= 32 ? 5 : 3;
const data = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
let r = 0;
let g = 0;
let b = 0;
let a = 0;
for (let sy = 0; sy < samples; sy += 1) {
for (let sx = 0; sx < samples; sx += 1) {
const scene = sampleScene((x + (sx + 0.5) / samples) * scale, (y + (sy + 0.5) / samples) * scale);
r += scene.r * scene.a;
g += scene.g * scene.a;
b += scene.b * scene.a;
a += scene.a;
}
}
const total = samples * samples;
const alpha = a / total;
const offset = (y * size + x) * 4;
data[offset] = alpha > 0 ? Math.round(r / a) : 0;
data[offset + 1] = alpha > 0 ? Math.round(g / a) : 0;
data[offset + 2] = alpha > 0 ? Math.round(b / a) : 0;
data[offset + 3] = Math.round(alpha * 255);
}
}
return encodePng(size, size, data);
}
const crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
crcTable[n] = c >>> 0;
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, payload) {
const typeBytes = Buffer.from(type);
const out = Buffer.alloc(12 + payload.length);
out.writeUInt32BE(payload.length, 0);
typeBytes.copy(out, 4);
payload.copy(out, 8);
out.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])), out.length - 4);
return out;
}
function encodePng(width, height, rgba) {
const header = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y += 1) {
const rowStart = y * (width * 4 + 1);
raw[rowStart] = 0;
rgba.copy(raw, rowStart + 1, y * width * 4, (y + 1) * width * 4);
}
return Buffer.concat([
header,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makeIco(images) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(images.length, 4);
const entries = Buffer.alloc(images.length * 16);
let offset = 6 + images.length * 16;
images.forEach((image, index) => {
const entry = index * 16;
entries[entry] = image.size >= 256 ? 0 : image.size;
entries[entry + 1] = image.size >= 256 ? 0 : image.size;
entries[entry + 2] = 0;
entries[entry + 3] = 0;
entries.writeUInt16LE(1, entry + 4);
entries.writeUInt16LE(32, entry + 6);
entries.writeUInt32LE(image.png.length, entry + 8);
entries.writeUInt32LE(offset, entry + 12);
offset += image.png.length;
});
return Buffer.concat([header, entries, ...images.map((image) => image.png)]);
}
mkdirSync(iconsDir, { recursive: true });
const iconPng = renderPng(512);
const icoImages = icoSizes.map((size) => ({ size, png: renderPng(size) }));
writeFileSync(join(iconsDir, "icon.png"), iconPng);
writeFileSync(join(iconsDir, "icon.ico"), makeIco(icoImages));
console.log(`Wrote ${join("src-tauri", "icons", "icon.png")} (${iconPng.length} bytes)`);
console.log(`Wrote ${join("src-tauri", "icons", "icon.ico")} (${icoImages.length} sizes)`);
+159 -1
View File
@@ -1932,6 +1932,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2396,6 +2397,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -3040,6 +3065,64 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3147,6 +3230,7 @@ dependencies = [
"serde", "serde",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
] ]
[[package]] [[package]]
@@ -4046,6 +4130,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -4079,13 +4172,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -4116,6 +4226,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4128,6 +4244,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4140,12 +4262,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4158,6 +4292,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -4170,6 +4310,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -4182,6 +4328,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -4194,6 +4346,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
+1
View File
@@ -9,6 +9,7 @@ build = "build.rs"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "=2.7.0"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
+2 -1
View File
@@ -10,6 +10,7 @@
"core:window:allow-unminimize", "core:window:allow-unminimize",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-start-dragging" "core:window:allow-start-dragging",
"dialog:allow-open"
] ]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 182 KiB

+680 -2
View File
@@ -1,9 +1,15 @@
use serde::Serialize; use serde::Serialize;
use std::{ use std::{
collections::BTreeMap, collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString}, ffi::{OsStr, OsString},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::{Command, Stdio},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
thread,
time::Duration,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -102,6 +108,21 @@ pub struct GitRepositoryFile {
pub status: Option<FileStatusKind>, pub status: Option<FileStatusKind>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitSearchHit {
pub commit_hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub date: String,
pub file: String,
pub old_file: Option<String>,
pub line_number: Option<u32>,
pub line: String,
pub matches_added: u32,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
struct BranchInfo { struct BranchInfo {
current_branch: Option<String>, current_branch: Option<String>,
@@ -118,6 +139,54 @@ enum CheckoutPlan {
} }
const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000"; const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000";
const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen.";
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Default, Clone)]
pub struct SearchCancellationState {
cancelled: Arc<Mutex<BTreeSet<String>>>,
}
impl SearchCancellationState {
fn cancel(&self, search_id: &str) -> Result<(), String> {
self.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.insert(search_id.to_string());
Ok(())
}
fn clear(&self, search_id: &str) -> Result<(), String> {
self.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.remove(search_id);
Ok(())
}
fn is_cancelled(&self, search_id: &str) -> Result<bool, String> {
Ok(self
.cancelled
.lock()
.map_err(|_| "Such-Abbruchstatus ist nicht erreichbar.".to_string())?
.contains(search_id))
}
}
#[derive(Clone)]
struct SearchCancellation {
state: SearchCancellationState,
search_id: String,
}
fn check_search_cancelled(cancellation: Option<&SearchCancellation>) -> Result<(), String> {
if let Some(cancellation) = cancellation {
if cancellation.state.is_cancelled(&cancellation.search_id)? {
return Err(SEARCH_CANCELLED_MESSAGE.to_string());
}
}
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn open_repository(path: String) -> Result<GitStatus, String> { pub fn open_repository(path: String) -> Result<GitStatus, String> {
@@ -394,6 +463,174 @@ pub fn list_file_history(
parse_commit_log(&repo, &output) parse_commit_log(&repo, &output)
} }
#[tauri::command]
pub async fn search_code_introductions(
path: String,
query: String,
case_sensitive: Option<bool>,
limit: Option<u32>,
search_id: Option<String>,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<Vec<GitSearchHit>, String> {
let state = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
let repo = resolve_repo(&path)?;
let query = normalize_newlines(&query);
let query = query.trim_matches('\n').to_string();
if query.trim().is_empty() {
return Err("Suchtext darf nicht leer sein.".to_string());
}
if verify_commit(&repo, "HEAD").is_err() {
return Ok(Vec::new());
}
let case_sensitive = case_sensitive.unwrap_or(false);
let limit = limit.unwrap_or(250).clamp(1, 1000) as usize;
let search_id = search_id
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty());
let cancellation = search_id.as_ref().map(|search_id| SearchCancellation {
state: state.clone(),
search_id: search_id.clone(),
});
let result = search_code_introductions_core(
&repo,
query,
case_sensitive,
limit,
cancellation.as_ref(),
);
if let Some(search_id) = search_id.as_deref() {
let _ = state.clear(search_id);
}
result
})
.await
.map_err(|err| format!("Such-Task konnte nicht abgeschlossen werden: {err}"))?
}
#[tauri::command]
pub fn cancel_code_search(
search_id: String,
state: tauri::State<'_, SearchCancellationState>,
) -> Result<(), String> {
let search_id = search_id.trim();
if search_id.is_empty() {
return Ok(());
}
state.cancel(search_id)
}
fn search_code_introductions_core(
repo: &Path,
query: String,
case_sensitive: bool,
limit: usize,
cancellation: Option<&SearchCancellation>,
) -> Result<Vec<GitSearchHit>, String> {
check_search_cancelled(cancellation)?;
let candidates = search_candidate_commits(&repo, &query, case_sensitive, cancellation)?;
let mut hits = Vec::new();
for commit in candidates {
check_search_cancelled(cancellation)?;
if hits.len() >= limit {
break;
}
let files = commit_files(&repo, &commit)?;
if files.is_empty() {
continue;
}
let parents = commit_parents(&repo, &commit)?;
let mut metadata: Option<GitSearchCommitMetadata> = None;
for file in files {
check_search_cancelled(cancellation)?;
if hits.len() >= limit {
break;
}
if matches!(file.status, FileStatusKind::Deleted) {
continue;
}
let Some(after_content) = read_text_blob(&repo, &commit, &file.path)? else {
continue;
};
let after_count = count_matches(&after_content, &query, case_sensitive);
if after_count == 0 {
continue;
}
let before_count = max_parent_match_count(
&repo,
&parents,
file.old_path.as_deref().unwrap_or(&file.path),
&query,
case_sensitive,
)?;
if after_count <= before_count {
continue;
}
let match_line = first_added_match_line(
&repo,
parents.first().map(String::as_str),
&commit,
&file.path,
&query,
case_sensitive,
cancellation,
)?
.or_else(|| first_match_line(&after_content, &query, case_sensitive));
let Some((line_number, line)) = match_line else {
continue;
};
let info = metadata
.get_or_insert_with(|| {
commit_search_metadata(&repo, &commit).unwrap_or_else(|_| {
GitSearchCommitMetadata {
commit_hash: commit.clone(),
short_hash: short_hash(&commit),
author_name: String::new(),
author_email: String::new(),
date: String::new(),
summary: String::new(),
}
})
})
.clone();
hits.push(GitSearchHit {
commit_hash: info.commit_hash,
short_hash: info.short_hash,
summary: info.summary,
author_name: info.author_name,
author_email: info.author_email,
date: info.date,
file: file.path,
old_file: file.old_path,
line_number: Some(line_number),
line,
matches_added: after_count.saturating_sub(before_count) as u32,
});
}
}
Ok(hits)
}
#[tauri::command] #[tauri::command]
pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, String> { pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -770,6 +1007,265 @@ fn has_unresolved_conflicts(status: &GitStatus) -> bool {
}) })
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct GitSearchCommitMetadata {
commit_hash: String,
short_hash: String,
author_name: String,
author_email: String,
date: String,
summary: String,
}
fn search_candidate_commits(
repo: &Path,
query: &str,
case_sensitive: bool,
cancellation: Option<&SearchCancellation>,
) -> Result<Vec<String>, String> {
let output = if query.contains('\n') {
run_git_cancellable(
repo,
["rev-list", "--all", "--reverse"],
cancellation,
"Git-Suche fehlgeschlagen",
)?
} else {
let mut args = vec![
OsString::from("log"),
OsString::from("--all"),
OsString::from("--reverse"),
OsString::from("--format=%H"),
];
if !case_sensitive {
args.push(OsString::from("-i"));
}
args.push(OsString::from(format!("-S{query}")));
run_git_cancellable(repo, args, cancellation, "Git-Suche fehlgeschlagen")?
};
Ok(String::from_utf8_lossy(&output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect())
}
fn commit_parents(repo: &Path, commit: &str) -> Result<Vec<String>, String> {
let output = run_git(repo, ["rev-list", "--parents", "-n", "1", commit])?;
let text = String::from_utf8_lossy(&output);
Ok(text
.split_whitespace()
.skip(1)
.map(ToString::to_string)
.collect())
}
fn max_parent_match_count(
repo: &Path,
parents: &[String],
file: &str,
query: &str,
case_sensitive: bool,
) -> Result<usize, String> {
let mut max_count = 0;
for parent in parents {
if let Some(content) = read_text_blob(repo, parent, file)? {
max_count = max_count.max(count_matches(&content, query, case_sensitive));
}
}
Ok(max_count)
}
fn read_text_blob(repo: &Path, commit: &str, file: &str) -> Result<Option<String>, String> {
let spec = format!("{commit}:{file}");
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["show", spec.as_str()])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
if !output.status.success() {
return Ok(None);
}
if is_binary_bytes(&output.stdout) {
return Ok(None);
}
Ok(Some(normalize_newlines(&String::from_utf8_lossy(
&output.stdout,
))))
}
fn normalize_newlines(value: &str) -> String {
value.replace("\r\n", "\n").replace('\r', "\n")
}
fn count_matches(content: &str, query: &str, case_sensitive: bool) -> usize {
let content = if case_sensitive {
content.to_string()
} else {
content.to_ascii_lowercase()
};
let query = if case_sensitive {
query.to_string()
} else {
query.to_ascii_lowercase()
};
if query.is_empty() {
return 0;
}
let mut count = 0;
let mut start = 0;
while let Some(index) = content[start..].find(&query) {
count += 1;
start += index + query.len();
}
count
}
fn first_added_match_line(
repo: &Path,
parent: Option<&str>,
commit: &str,
file: &str,
query: &str,
case_sensitive: bool,
cancellation: Option<&SearchCancellation>,
) -> Result<Option<(u32, String)>, String> {
let Some(parent) = parent else {
return Ok(None);
};
check_search_cancelled(cancellation)?;
let output = run_git_with_paths_cancellable(
repo,
&["diff", "--unified=0", parent, commit],
&[file.to_string()],
cancellation,
"Git-Diff fuer Suchtreffer fehlgeschlagen",
)?;
let patch = String::from_utf8_lossy(&output);
let line_query = first_query_line(query);
let mut new_line = 0u32;
for line in patch.lines() {
check_search_cancelled(cancellation)?;
if line.starts_with("@@") {
if let Some(start) = parse_new_hunk_start(line) {
new_line = start;
}
continue;
}
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("diff ") {
continue;
}
if let Some(added) = line.strip_prefix('+') {
if count_matches(added, line_query, case_sensitive) > 0 {
return Ok(Some((new_line.max(1), compact_search_line(added))));
}
new_line = new_line.saturating_add(1);
} else if line.starts_with('-') {
continue;
} else if line.starts_with(' ') {
new_line = new_line.saturating_add(1);
}
}
Ok(None)
}
fn first_query_line(query: &str) -> &str {
query
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(query)
}
fn parse_new_hunk_start(line: &str) -> Option<u32> {
let plus = line.split_whitespace().find(|part| part.starts_with('+'))?;
let number = plus
.trim_start_matches('+')
.split_once(',')
.map(|(start, _)| start)
.unwrap_or_else(|| plus.trim_start_matches('+'));
number.parse().ok()
}
fn first_match_line(content: &str, query: &str, case_sensitive: bool) -> Option<(u32, String)> {
let haystack = if case_sensitive {
content.to_string()
} else {
content.to_ascii_lowercase()
};
let needle = if case_sensitive {
query.to_string()
} else {
query.to_ascii_lowercase()
};
let index = haystack.find(&needle)?;
let line_number = content[..index]
.bytes()
.filter(|byte| *byte == b'\n')
.count() as u32
+ 1;
let line_start = content[..index].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
let line_end = content[index..]
.find('\n')
.map(|pos| index + pos)
.unwrap_or(content.len());
Some((
line_number,
compact_search_line(&content[line_start..line_end]),
))
}
fn compact_search_line(line: &str) -> String {
const MAX_LEN: usize = 240;
let compact = line.trim().replace('\t', " ");
if compact.chars().count() <= MAX_LEN {
return compact;
}
let mut shortened: String = compact.chars().take(MAX_LEN).collect();
shortened.push_str("...");
shortened
}
fn commit_search_metadata(repo: &Path, commit: &str) -> Result<GitSearchCommitMetadata, String> {
let output = run_git(
repo,
[
"show",
"-s",
"--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s",
commit,
],
)?;
let text = String::from_utf8_lossy(&output);
let fields: Vec<&str> = text.trim_end().splitn(6, '\x1f').collect();
if fields.len() != 6 {
return Err(format!("Unerwarteter Git-Commit-Eintrag: {text}"));
}
Ok(GitSearchCommitMetadata {
commit_hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
author_name: fields[2].to_string(),
author_email: fields[3].to_string(),
date: fields[4].to_string(),
summary: fields[5].to_string(),
})
}
fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> { fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> {
const FIELD_SEPARATOR: char = '\x1f'; const FIELD_SEPARATOR: char = '\x1f';
const RECORD_SEPARATOR: char = '\x1e'; const RECORD_SEPARATOR: char = '\x1e';
@@ -1283,6 +1779,20 @@ fn run_git_with_paths(
run_git(repo, args) run_git(repo, args)
} }
fn run_git_with_paths_cancellable(
repo: &Path,
base_args: &[&str],
files: &[String],
cancellation: Option<&SearchCancellation>,
context: &str,
) -> Result<Vec<u8>, String> {
let mut args = Vec::with_capacity(base_args.len() + files.len() + 1);
args.extend(base_args.iter().map(OsString::from));
args.push(OsString::from("--"));
args.extend(files.iter().map(OsString::from));
run_git_cancellable(repo, args, cancellation, context)
}
fn run_git<I, S>(repo: &Path, args: I) -> Result<Vec<u8>, String> fn run_git<I, S>(repo: &Path, args: I) -> Result<Vec<u8>, String>
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
@@ -1291,6 +1801,91 @@ where
run_git_at(repo, args, "Git-Befehl fehlgeschlagen") run_git_at(repo, args, "Git-Befehl fehlgeschlagen")
} }
fn run_git_cancellable<I, S>(
repo: &Path,
args: I,
cancellation: Option<&SearchCancellation>,
context: &str,
) -> Result<Vec<u8>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
check_search_cancelled(cancellation)?;
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
let temp_dir = std::env::temp_dir();
let stdout_path = temp_dir.join(format!(
"gitlite_search_{}_{}.out",
std::process::id(),
counter
));
let stderr_path = temp_dir.join(format!(
"gitlite_search_{}_{}.err",
std::process::id(),
counter
));
let stdout_file = std::fs::File::create(&stdout_path)
.map_err(|err| format!("Git-Ausgabedatei konnte nicht erstellt werden: {err}"))?;
let stderr_file = std::fs::File::create(&stderr_path)
.map_err(|err| format!("Git-Fehlerdatei konnte nicht erstellt werden: {err}"))?;
let mut child = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
.spawn()
.map_err(|err| {
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}")
})?;
let status = loop {
if let Err(err) = check_search_cancelled(cancellation) {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
return Err(err);
}
if let Some(status) = child
.try_wait()
.map_err(|err| format!("Git-Prozess konnte nicht geprueft werden: {err}"))?
{
break status;
}
thread::sleep(Duration::from_millis(60));
};
let stdout = std::fs::read(&stdout_path)
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?;
let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Git-Fehlerausgabe konnte nicht gelesen werden: {err}"))?;
let _ = std::fs::remove_file(&stdout_path);
let _ = std::fs::remove_file(&stderr_path);
if status.success() {
return Ok(stdout);
}
let stderr_text = String::from_utf8_lossy(&stderr);
let stdout_text = String::from_utf8_lossy(&stdout);
let details = if !stderr_text.trim().is_empty() {
stderr_text.trim()
} else if !stdout_text.trim().is_empty() {
stdout_text.trim()
} else {
"unbekannter Fehler"
};
Err(format!("{context}: {details}"))
}
fn run_git_at<I, S>(path: &Path, args: I, context: &str) -> Result<Vec<u8>, String> fn run_git_at<I, S>(path: &Path, args: I, context: &str) -> Result<Vec<u8>, String>
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
@@ -1565,6 +2160,89 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]); run_git_test(repo, ["commit", "-q", "-m", "init"]);
} }
#[test]
fn search_code_introductions_finds_added_string() {
let repo = init_temp_repo("search_added_string");
fs::create_dir_all(repo.path.join("src")).expect("src directory should be created");
fs::write(repo.path.join("src/app.ts"), "export const existing = 1;\n")
.expect("initial source file should be written");
run_git_test(&repo.path, ["add", "src/app.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial source"]);
fs::write(
repo.path.join("src/app.ts"),
"export const existing = 1;\n\nexport function renderWidget() {\n return \"needle-token\";\n}\n",
)
.expect("updated source file should be written");
run_git_test(&repo.path, ["add", "src/app.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add render widget"]);
let hits =
search_code_introductions_core(&repo.path, "renderWidget".to_string(), false, 20, None)
.expect("search should succeed");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file, "src/app.ts");
assert_eq!(hits[0].summary, "add render widget");
assert_eq!(hits[0].line_number, Some(3));
}
#[test]
fn search_code_introductions_finds_multiline_function_block() {
let repo = init_temp_repo("search_multiline_function");
fs::write(repo.path.join("module.ts"), "export const ready = true;\n")
.expect("initial module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "initial module"]);
let function_body = "export function parseThing() {\n return \"needle-token\";\n}";
fs::write(
repo.path.join("module.ts"),
format!("export const ready = true;\n\n{function_body}\n"),
)
.expect("updated module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add parser"]);
let hits =
search_code_introductions_core(&repo.path, function_body.to_string(), false, 20, None)
.expect("search should succeed");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file, "module.ts");
assert_eq!(hits[0].summary, "add parser");
assert_eq!(hits[0].line_number, Some(3));
}
#[test]
fn search_code_introductions_can_be_cancelled() {
let repo = init_temp_repo("search_cancelled");
fs::write(
repo.path.join("module.ts"),
"export const value = \"needle\";\n",
)
.expect("module should be written");
run_git_test(&repo.path, ["add", "module.ts"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "add module"]);
let state = SearchCancellationState::default();
state
.cancel("test-search")
.expect("cancel flag should be set");
let result = search_code_introductions_core(
&repo.path,
"needle".to_string(),
false,
20,
Some(&SearchCancellation {
state: state.clone(),
search_id: "test-search".to_string(),
}),
);
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
}
#[test] #[test]
fn parses_branch_tracking_and_file_states() { fn parses_branch_tracking_and_file_states() {
let raw = b"## main...origin/main [ahead 2, behind 1]\0 M changed.txt\0D deleted.txt\0?? new.txt\0"; let raw = b"## main...origin/main [ahead 2, behind 1]\0 M changed.txt\0D deleted.txt\0?? new.txt\0";
+9 -4
View File
@@ -3,14 +3,17 @@
mod git; mod git;
use git::{ use git::{
checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status, cancel_code_search, checkout_branch, commit, compare_commits, diff_file_against_working_tree,
list_branches, list_commits, list_file_history, list_repository_files, merge_branch, get_status, list_branches, list_commits, list_file_history, list_repository_files,
open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side, merge_branch, open_repository, pull, push, read_conflict, resolve_conflict,
restore_file_from_commit, restore_files, restore_to_commit, stage_files, unstage_files, resolve_conflict_side, restore_file_from_commit, restore_files, restore_to_commit,
search_code_introductions, stage_files, unstage_files, SearchCancellationState,
}; };
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.manage(SearchCancellationState::default())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
get_status, get_status,
@@ -30,6 +33,8 @@ fn main() {
list_file_history, list_file_history,
compare_commits, compare_commits,
diff_file_against_working_tree, diff_file_against_working_tree,
search_code_introductions,
cancel_code_search,
read_conflict, read_conflict,
resolve_conflict, resolve_conflict,
resolve_conflict_side resolve_conflict_side
+102 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { AlertCircle, Check, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte"; import TitleBar from "./lib/TitleBar.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
@@ -10,6 +11,7 @@
import CredentialDialog from "./lib/components/CredentialDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte"; import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
@@ -18,6 +20,7 @@
checkoutBranch, checkoutBranch,
commit, commit,
compareCommits, compareCommits,
cancelCodeSearch,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
getStatus, getStatus,
listBranches, listBranches,
@@ -34,6 +37,7 @@
restoreFileFromCommit, restoreFileFromCommit,
restoreFiles, restoreFiles,
restoreToCommit, restoreToCommit,
searchCodeIntroductions,
stageFiles, stageFiles,
unstageFiles, unstageFiles,
} from "./lib/git"; } from "./lib/git";
@@ -49,6 +53,7 @@
GitDiffFile, GitDiffFile,
GitFileStatus, GitFileStatus,
GitRepositoryFile, GitRepositoryFile,
GitSearchHit,
GitStatus, GitStatus,
PreparedResolution, PreparedResolution,
} from "./lib/types"; } from "./lib/types";
@@ -74,6 +79,11 @@
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
let compareDialogOpen = false; let compareDialogOpen = false;
let selectedDiffPath = ""; let selectedDiffPath = "";
let globalSearchOpen = false;
let globalSearchResults: GitSearchHit[] = [];
let globalSearchBusy = false;
let globalSearchError = "";
let globalSearchId = "";
let resolveDialogOpen = false; let resolveDialogOpen = false;
let conflictTarget = ""; let conflictTarget = "";
let conflict: ConflictFile | null = null; let conflict: ConflictFile | null = null;
@@ -122,7 +132,7 @@
} }
async function autoRefreshTick() { async function autoRefreshTick() {
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen) return; if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || globalSearchOpen) return;
autoRefreshInFlight = true; autoRefreshInFlight = true;
try { try {
const nextStatus = await getStatus(activeRepoPath); const nextStatus = await getStatus(activeRepoPath);
@@ -220,9 +230,10 @@
// ── Repository operations ────────────────────────────────────────────────── // ── Repository operations ──────────────────────────────────────────────────
async function openRepo() { async function openRepo(pathOverride?: string) {
const path = repoPath.trim(); const path = (pathOverride ?? repoPath).trim();
if (!path) { errorMessage = "Enter a repository path."; return; } if (!path) { errorMessage = "Enter a repository path."; return; }
repoPath = path;
await runOperation("Opening repository", async () => { await runOperation("Opening repository", async () => {
const nextStatus = await openRepository(path); const nextStatus = await openRepository(path);
@@ -232,6 +243,8 @@
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set(); expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
fileHistory = []; compareFrom = ""; compareTo = ""; fileHistory = []; compareFrom = ""; compareTo = "";
comparison = null; compareDialogOpen = false; selectedDiffPath = ""; comparison = null; compareDialogOpen = false; selectedDiffPath = "";
if (globalSearchBusy) void cancelGlobalSearch();
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
resolveDialogOpen = false; conflictTarget = ""; conflict = null; resolveDialogOpen = false; conflictTarget = ""; conflict = null;
preparedResolutions = {}; preparedResolutions = {};
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
@@ -240,6 +253,23 @@
}); });
} }
async function chooseRepositoryFolder() {
if (isBusy) return;
try {
const selected = await openDialog({
title: "Repository folder auswaehlen",
directory: true,
multiple: false,
defaultPath: repoPath.trim() || activeRepoPath || undefined,
});
if (typeof selected !== "string") return;
repoPath = selected;
await openRepo(selected);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function refreshRepo() { async function refreshRepo() {
if (!activeRepoPath) { await openRepo(); return; } if (!activeRepoPath) { await openRepo(); return; }
await runOperation("Refreshing", async () => { await runOperation("Refreshing", async () => {
@@ -474,6 +504,48 @@
selectedDiffPath = file.path; selectedDiffPath = file.path;
} }
async function runGlobalSearch(query: string, caseSensitive: boolean, limit: number) {
if (!activeRepoPath || globalSearchBusy) return;
const searchId = `search-${Date.now()}-${Math.random().toString(36).slice(2)}`;
globalSearchId = searchId;
globalSearchBusy = true;
globalSearchError = "";
globalSearchResults = [];
try {
const results = await searchCodeIntroductions(activeRepoPath, query, caseSensitive, limit, searchId);
if (globalSearchId === searchId) {
globalSearchResults = results;
}
} catch (error) {
if (globalSearchId === searchId) {
const message = errorToMessage(error);
globalSearchError = message.includes("abgebrochen") ? "Suche wurde abgebrochen." : message;
}
} finally {
if (globalSearchId === searchId) {
globalSearchBusy = false;
globalSearchId = "";
}
}
}
async function cancelGlobalSearch() {
if (!globalSearchId) return;
const searchId = globalSearchId;
globalSearchError = "Abbruch wird angefordert...";
try {
await cancelCodeSearch(searchId);
} catch (error) {
globalSearchError = errorToMessage(error);
}
}
function closeGlobalSearchDialog() {
if (globalSearchBusy) void cancelGlobalSearch();
globalSearchOpen = false;
}
// ── Conflict resolution ──────────────────────────────────────────────────── // ── Conflict resolution ────────────────────────────────────────────────────
async function loadConflict(file: string) { async function loadConflict(file: string) {
@@ -543,6 +615,7 @@
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) compareDialogOpen = false; if (event.key === "Escape" && compareDialogOpen) compareDialogOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
} }
</script> </script>
@@ -566,6 +639,7 @@
onPull={pullRepo} onPull={pullRepo}
onPush={pushRepo} onPush={pushRepo}
onRefresh={refreshRepo} onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }}
onToggleAutoRefresh={toggleAutoRefresh} onToggleAutoRefresh={toggleAutoRefresh}
/> />
@@ -583,6 +657,17 @@
placeholder="/path/to/repository" placeholder="/path/to/repository"
disabled={isBusy} disabled={isBusy}
/> />
<button
class="btn-secondary repo-browse"
type="button"
onclick={chooseRepositoryFolder}
disabled={isBusy}
title="Repository-Ordner auswaehlen"
aria-label="Repository-Ordner auswaehlen"
>
<FolderOpen size={16} aria-hidden="true" />
Browse
</button>
<button class="btn-primary" type="submit" disabled={isBusy || repoPath.trim().length === 0}> <button class="btn-primary" type="submit" disabled={isBusy || repoPath.trim().length === 0}>
{#if operation === "Opening repository"} {#if operation === "Opening repository"}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
@@ -743,6 +828,19 @@
/> />
{/if} {/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{hasRepository}
{isBusy}
isSearching={globalSearchBusy}
error={globalSearchError}
results={globalSearchResults}
onClose={closeGlobalSearchDialog}
onSearch={runGlobalSearch}
onCancel={cancelGlobalSearch}
/>
{/if}
<!-- Credential dialog for push/pull --> <!-- Credential dialog for push/pull -->
{#if credDialogOpen && credDialogAction} {#if credDialogOpen && credDialogAction}
<CredentialDialog <CredentialDialog
+286 -50
View File
@@ -1,29 +1,29 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-ink: #dde1f0; --color-ink: #f5f7ff;
--color-ink-muted: #8090be; --color-ink-muted: #a8b1d8;
--color-ink-faint: #3c4668; --color-ink-faint: #687197;
--color-ink-dim: #5e6e9c; --color-ink-dim: #838cb8;
--color-ink-quiet: #7880ac; --color-ink-quiet: #949bc8;
--color-surface: #161a24; --color-surface: rgba(22, 22, 36, 0.82);
--color-surface-alt: #0e1118; --color-surface-alt: #0b0b14;
--color-surface-dim: #191d2a; --color-surface-dim: rgba(18, 18, 30, 0.9);
--color-surface-hover: #202438; --color-surface-hover: rgba(47, 48, 78, 0.76);
--color-surface-raised: #1c2033; --color-surface-raised: rgba(28, 29, 48, 0.88);
--color-border: #2a2f45; --color-border: rgba(100, 108, 255, 0.28);
--color-border-subtle: #1e2236; --color-border-subtle: rgba(255, 255, 255, 0.08);
--color-border-input: #323658; --color-border-input: rgba(65, 209, 255, 0.26);
--color-primary: #5a8cf8; --color-primary: #646cff;
--color-primary-dark: #4878f0; --color-primary-dark: #535bf2;
--color-accent: #6a9aff; --color-accent: #41d1ff;
--color-bar: #090c14; --color-bar: rgba(9, 9, 18, 0.92);
--color-bar-text: #c0cce0; --color-bar-text: #f4f6ff;
--color-bar-muted: #4a5a78; --color-bar-muted: #7b84b2;
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace; --font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
} }
@@ -43,7 +43,11 @@
body { body {
overflow: hidden; overflow: hidden;
color: var(--color-ink); color: var(--color-ink);
background: var(--color-surface-alt); background:
radial-gradient(circle at 22% 8%, rgba(189, 52, 254, 0.28), transparent 34%),
radial-gradient(circle at 74% 10%, rgba(65, 209, 255, 0.22), transparent 30%),
radial-gradient(circle at 88% 78%, rgba(255, 211, 67, 0.1), transparent 26%),
linear-gradient(135deg, #090912 0%, #111122 45%, #0b0b14 100%);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -61,9 +65,9 @@
min-height: 30px; min-height: 30px;
padding: 0 11px; padding: 0 11px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 8px;
color: var(--color-ink-muted); color: var(--color-ink-muted);
background: var(--color-surface-raised); background: linear-gradient(180deg, rgba(42, 43, 70, 0.9), rgba(27, 28, 48, 0.92));
cursor: pointer; cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease; transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
font-size: 13px; font-size: 13px;
@@ -75,20 +79,21 @@
} }
button:disabled { cursor: not-allowed; opacity: 0.4; } button:disabled { cursor: not-allowed; opacity: 0.4; }
input, textarea { input, textarea, select {
width: 100%; width: 100%;
border: 1px solid var(--color-border-input); border: 1px solid var(--color-border-input);
border-radius: 6px; border-radius: 8px;
color: var(--color-ink); color: var(--color-ink);
background: var(--color-surface-raised); background: rgba(13, 14, 26, 0.78);
outline: none; outline: none;
} }
input:focus, textarea:focus { input:focus, textarea:focus, select:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(90, 140, 248, 0.18); box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.22), 0 0 32px rgba(65, 209, 255, 0.08);
} }
input { height: 34px; padding: 0 11px; } input { height: 34px; padding: 0 11px; }
textarea { resize: vertical; min-height: 132px; padding: 10px 11px; line-height: 1.45; } textarea { resize: vertical; min-height: 132px; padding: 10px 11px; line-height: 1.45; }
select { height: 34px; padding: 0 9px; }
select { select {
appearance: none; appearance: none;
@@ -120,22 +125,40 @@
.btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; } .btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; }
.btn-primary { .btn-primary {
border-color: var(--color-primary); border-color: rgba(100, 108, 255, 0.75);
color: #ffffff; color: #ffffff;
background: var(--color-primary); background: linear-gradient(135deg, #646cff 0%, #bd34fe 100%);
box-shadow: 0 0 24px rgba(100, 108, 255, 0.2);
font-weight: 600; font-weight: 600;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
border-color: var(--color-primary-dark); border-color: rgba(65, 209, 255, 0.82);
background: var(--color-primary-dark); background: linear-gradient(135deg, #747bff 0%, #c966ff 100%);
color: #ffffff; color: #ffffff;
} }
.btn-secondary {
border-color: rgba(65, 209, 255, 0.32);
color: #b9e9ff;
background: linear-gradient(180deg, rgba(65, 209, 255, 0.12), rgba(100, 108, 255, 0.1));
font-weight: 700;
}
.btn-secondary:hover:not(:disabled) {
border-color: rgba(65, 209, 255, 0.64);
color: #ffffff;
background: linear-gradient(180deg, rgba(65, 209, 255, 0.18), rgba(100, 108, 255, 0.16));
}
.panel { .panel {
min-width: 0;
min-height: 0; min-height: 0;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 10px; border-radius: 12px;
background: var(--color-surface); background:
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 72%),
var(--color-surface);
box-shadow: 0 18px 60px rgba(0,0,0,0.24), inset 0 1px 0 rgba(255,255,255,0.05);
backdrop-filter: blur(16px);
} }
.section-head { .section-head {
@@ -146,6 +169,7 @@
min-height: 46px; min-height: 46px;
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
background: linear-gradient(90deg, rgba(100,108,255,0.09), rgba(65,209,255,0.025));
} }
.section-head h2 { margin: 1px 0 0; color: var(--color-ink); font-size: 14px; line-height: 1.2; font-weight: 600; } .section-head h2 { margin: 1px 0 0; color: var(--color-ink); font-size: 14px; line-height: 1.2; font-weight: 600; }
@@ -209,14 +233,14 @@
/* --- App shell --- */ /* --- App shell --- */
.shell { display: flex; flex-direction: column; height: 100%; background: var(--color-surface-alt); } .shell { display: flex; flex-direction: column; height: 100%; background: transparent; }
.shell-body { .shell-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
padding: 0 10px 10px; padding: 8px 12px 12px;
gap: 8px; gap: 8px;
} }
@@ -226,8 +250,10 @@
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
align-items: center; align-items: center;
height: 38px; height: 42px;
background: var(--color-bar); background:
linear-gradient(90deg, rgba(100,108,255,0.16), rgba(65,209,255,0.08), rgba(255,211,67,0.035)),
var(--color-bar);
color: var(--color-bar-text); color: var(--color-bar-text);
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;
@@ -246,7 +272,7 @@
color: var(--color-bar-text); color: var(--color-bar-text);
border-right: 1px solid rgba(255,255,255,0.06); border-right: 1px solid rgba(255,255,255,0.06);
} }
.titlebar-brand svg { color: #5a8cf8; flex-shrink: 0; } .titlebar-brand svg { color: #ffd343; filter: drop-shadow(0 0 10px rgba(255,211,67,0.3)); flex-shrink: 0; }
.titlebar-info { .titlebar-info {
display: flex; display: flex;
@@ -257,17 +283,17 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
.titlebar-info svg { color: #5a8cf8; flex-shrink: 0; } .titlebar-info svg { color: #41d1ff; flex-shrink: 0; }
.tb-repo { color: #506070; font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; } .tb-repo { color: #8e95c9; font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
.tb-sep { color: #2a3440; font-size: 13px; } .tb-sep { color: rgba(255,255,255,0.22); font-size: 13px; }
.tb-branch { color: #c0cce0; font-size: 12px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; } .tb-branch { color: #f5f7ff; font-size: 12px; font-weight: 700; font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
.tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; } .tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; }
.tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); } .tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
.tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); } .tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); }
.tb-no-repo { color: #3a4a5a; font-size: 12px; font-style: italic; } .tb-no-repo { color: #7b84b2; font-size: 12px; font-style: italic; }
.titlebar-right { display: flex; align-items: stretch; height: 100%; border-left: 1px solid rgba(255,255,255,0.06); } .titlebar-right { display: flex; align-items: stretch; height: 100%; border-left: 1px solid rgba(255,255,255,0.06); }
.titlebar-actions { display: flex; align-items: stretch; } .titlebar-actions { display: flex; align-items: stretch; }
@@ -319,15 +345,25 @@
/* --- Top bar (repo form) --- */ /* --- Top bar (repo form) --- */
.topbar { padding: 7px 10px; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface); } .topbar {
padding: 9px 10px;
border: 1px solid rgba(100,108,255,0.28);
border-radius: 12px;
background:
linear-gradient(90deg, rgba(189,52,254,0.1), rgba(65,209,255,0.075), rgba(255,211,67,0.035)),
var(--color-surface);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
backdrop-filter: blur(16px);
}
.repo-form { .repo-form {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
} }
.repo-form label { color: var(--color-ink-faint); font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; } .repo-form label { color: var(--color-ink-faint); font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.repo-browse { min-width: 104px; }
/* --- Notices --- */ /* --- Notices --- */
@@ -367,6 +403,7 @@
display: grid; display: grid;
grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) clamp(400px, 40vw, 620px); grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) clamp(400px, 40vw, 620px);
flex: 1 1 0; flex: 1 1 0;
min-width: 0;
min-height: 0; min-height: 0;
gap: 8px; gap: 8px;
} }
@@ -374,6 +411,7 @@
.history-aside { .history-aside {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
min-width: 0;
min-height: 0; min-height: 0;
gap: 8px; gap: 8px;
} }
@@ -381,6 +419,7 @@
.left-sidebar { .left-sidebar {
display: grid; display: grid;
grid-template-rows: minmax(200px, 0.9fr) minmax(240px, 1.1fr); grid-template-rows: minmax(200px, 0.9fr) minmax(240px, 1.1fr);
min-width: 0;
min-height: 0; min-height: 0;
gap: 8px; gap: 8px;
} }
@@ -390,6 +429,7 @@
.main-panel { .main-panel {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
min-height: 0; min-height: 0;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 10px; border-radius: 10px;
@@ -619,9 +659,9 @@
/* --- Commit history --- */ /* --- Commit history --- */
.history-list { padding: 6px; overflow: auto; } .history-list { min-width: 0; padding: 6px; overflow: auto; }
.commit-row { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; } .commit-row { display: grid; min-width: 0; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); transition: border-color 120ms; }
.commit-row + .commit-row { margin-top: 5px; } .commit-row + .commit-row { margin-top: 5px; }
.commit-row:hover { border-color: var(--color-border); } .commit-row:hover { border-color: var(--color-border); }
.commit-row.compact { padding: 8px; } .commit-row.compact { padding: 8px; }
@@ -629,6 +669,7 @@
.commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; } .commit-line { display: flex; align-items: flex-start; min-width: 0; gap: 8px; }
.commit-line svg { flex: 0 0 auto; margin-top: 2px; color: var(--color-accent); } .commit-line svg { flex: 0 0 auto; margin-top: 2px; color: var(--color-accent); }
.commit-line div { min-width: 0; } .commit-line div { min-width: 0; }
.commit-line-text { max-width: 100%; overflow: hidden; }
.commit-line strong { display: block; overflow: hidden; color: var(--color-ink); font-size: 13px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } .commit-line strong { display: block; overflow: hidden; color: var(--color-ink); font-size: 13px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.commit-line span { display: block; overflow: hidden; margin-top: 3px; color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .commit-line span { display: block; overflow: hidden; margin-top: 3px; color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
@@ -643,9 +684,40 @@
.commit-file-button { display: grid; grid-template-columns: auto minmax(0, 1fr); justify-content: stretch; width: 100%; min-height: 28px; padding: 4px 7px; text-align: left; border-color: var(--color-border-subtle); background: rgba(255,255,255,0.025); } .commit-file-button { display: grid; grid-template-columns: auto minmax(0, 1fr); justify-content: stretch; width: 100%; min-height: 28px; padding: 4px 7px; text-align: left; border-color: var(--color-border-subtle); background: rgba(255,255,255,0.025); }
.commit-file-button strong { overflow: hidden; color: var(--color-ink-muted); font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .commit-file-button strong { overflow: hidden; color: var(--color-ink-muted); font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.commit-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .commit-actions { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; }
.commit-actions time { overflow: hidden; color: var(--color-ink-faint); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .commit-actions time { min-width: 0; overflow: hidden; color: var(--color-ink-faint); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.commit-action-buttons { display: flex; align-items: center; gap: 5px; } .commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
.file-history-head { align-items: flex-start; }
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
.section-head .file-history-name {
display: -webkit-box;
max-height: 2.4em;
margin: 2px 0 0;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-count { flex: 0 0 auto; margin-top: 2px; }
.file-history-row { grid-template-columns: minmax(0, 1fr); min-width: 0; overflow: hidden; }
.file-history-row .commit-line { overflow: hidden; }
.file-history-row .commit-line strong {
display: -webkit-box;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-actions { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; align-items: center; gap: 6px; }
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
/* --- Git graph --- */ /* --- Git graph --- */
@@ -739,6 +811,10 @@
width: min(1560px, calc(100vw - 32px)); width: min(1560px, calc(100vw - 32px));
height: min(940px, calc(100vh - 32px)); height: min(940px, calc(100vh - 32px));
} }
.global-search-dialog {
width: min(1180px, calc(100vw - 32px));
height: min(840px, calc(100vh - 32px));
}
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); } .dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
@@ -890,6 +966,165 @@
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; } .prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
.global-search-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.global-search-form {
display: grid;
gap: 10px;
padding: 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: linear-gradient(90deg, rgba(100,108,255,0.08), rgba(65,209,255,0.035));
}
.global-search-query { display: grid; gap: 6px; min-width: 0; }
.global-search-query span,
.search-limit span {
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.global-search-query textarea {
min-height: 112px;
max-height: 240px;
resize: vertical;
font-family: var(--font-mono);
font-size: 12px;
white-space: pre;
overflow: auto;
}
.global-search-options {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px auto auto;
align-items: end;
gap: 10px;
}
.check-row {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--color-ink-muted);
font-size: 12px;
font-weight: 700;
}
.check-row input { width: 15px; height: 15px; flex: 0 0 auto; }
.search-limit { display: grid; gap: 5px; }
.search-cancel {
border-color: rgba(232,96,96,0.35);
color: #ef9090;
background: rgba(232,96,96,0.1);
}
.search-cancel:hover:not(:disabled) {
border-color: rgba(232,96,96,0.55);
color: #ffb0b0;
background: rgba(232,96,96,0.16);
}
.global-search-results {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 10px;
}
.search-result-head {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
margin-bottom: 8px;
color: var(--color-ink-dim);
font-size: 12px;
font-weight: 700;
}
.search-result-head strong { color: var(--color-accent); font-family: var(--font-mono); }
.search-result-head span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-error { color: #ef9090; }
.search-hit-list { display: grid; gap: 8px; }
.search-hit {
display: grid;
gap: 7px;
min-width: 0;
padding: 10px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
}
.search-hit-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.search-hit-top .hash {
padding: 2px 7px;
border: 1px solid rgba(90,140,248,0.22);
border-radius: 6px;
color: var(--color-accent);
background: rgba(90,140,248,0.13);
font-family: var(--font-mono);
font-size: 11.5px;
font-weight: 800;
}
.search-hit-top strong {
min-width: 0;
overflow: hidden;
color: var(--color-ink);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-hit-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
color: var(--color-ink-faint);
font-size: 11.5px;
}
.search-hit-meta span,
.search-hit-file {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 5px;
}
.search-hit-meta svg,
.search-hit-file svg { flex: 0 0 auto; color: var(--color-ink-faint); }
.search-hit-file {
overflow: hidden;
color: var(--color-ink-muted);
font-family: var(--font-mono);
font-size: 12px;
}
.search-hit-file span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-hit-file strong { flex: 0 0 auto; color: var(--color-accent); }
.search-hit-line {
overflow: auto;
margin: 0;
padding: 8px 10px;
border-radius: 7px;
color: #5dd88a;
background: rgba(78,202,118,0.08);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.45;
white-space: pre;
}
/* --- Credential dialog --- */ /* --- Credential dialog --- */
.cred-card { .cred-card {
@@ -1415,6 +1650,7 @@
.compare-arrow { display: none; } .compare-arrow { display: none; }
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); } .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); } .compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.global-search-options { grid-template-columns: 1fr; }
.dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); } .dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
.branch-actions { flex-direction: row; justify-content: flex-start; } .branch-actions { flex-direction: row; justify-content: flex-start; }
.tb-action-label { display: none; } .tb-action-label { display: none; }
+13 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Upload, X } from "@lucide/svelte"; import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = ""; export let branch: string = "";
export let ahead: number = 0; export let ahead: number = 0;
@@ -15,6 +15,7 @@
export let onPull: () => void = () => {}; export let onPull: () => void = () => {};
export let onPush: () => void = () => {}; export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {}; export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {}; export let onToggleAutoRefresh: () => void = () => {};
const win = getCurrentWindow(); const win = getCurrentWindow();
@@ -80,6 +81,17 @@
<!-- Right: actions + window controls --> <!-- Right: actions + window controls -->
<div class="titlebar-right"> <div class="titlebar-right">
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions"> <div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
<button
class="tb-action"
onclick={onSearch}
disabled={!hasRepository || isBusy}
title="Global search"
aria-label="Global search"
>
<Search size={14} aria-hidden="true" />
<span class="tb-action-label">Search</span>
</button>
<button <button
class="tb-action" class="tb-action"
onclick={onPull} onclick={onPull}
+7 -7
View File
@@ -74,15 +74,15 @@
</script> </script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Selected file history"> <section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Selected file history">
<div class="section-head"> <div class="section-head file-history-head">
<div class="min-w-0 flex-1 overflow-hidden"> <div class="file-history-heading">
<span class="eyebrow">{selectedExplorerLabel}</span> <span class="eyebrow">{selectedExplorerLabel}</span>
<h2 <h2
class="mt-0.5 text-ink text-base font-bold leading-tight truncate" class="file-history-name"
use:pathTooltip={selectedExplorerPath} use:pathTooltip={selectedExplorerPath}
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2> >{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
</div> </div>
<span class="pill pill-count flex-shrink-0">{fileHistory.length}</span> <span class="pill pill-count file-history-count">{fileHistory.length}</span>
</div> </div>
{#if !hasRepository} {#if !hasRepository}
@@ -94,16 +94,16 @@
{:else} {:else}
<div class="history-list overflow-auto p-2"> <div class="history-list overflow-auto p-2">
{#each fileHistory as item (item.hash)} {#each fileHistory as item (item.hash)}
<article class="commit-row compact"> <article class="commit-row compact file-history-row">
<div class="commit-line"> <div class="commit-line">
<History size={16} aria-hidden="true" /> <History size={16} aria-hidden="true" />
<div> <div class="commit-line-text">
<strong title={item.summary}>{item.summary}</strong> <strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span> <span>{item.short_hash} - {item.author_name}</span>
</div> </div>
</div> </div>
<div class="commit-actions"> <div class="commit-actions file-history-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time> <time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons"> <div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree"> <button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
@@ -0,0 +1,174 @@
<script lang="ts">
import { CalendarDays, FileCode, LoaderCircle, Search, User, X } from "@lucide/svelte";
import type { GitSearchHit } from "../types";
interface Props {
hasRepository: boolean;
isBusy: boolean;
isSearching: boolean;
error: string;
results: GitSearchHit[];
onClose: () => void;
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
onCancel: () => void | Promise<void>;
}
let {
hasRepository = false,
isBusy = false,
isSearching = false,
error = "",
results = [],
onClose = () => {},
onSearch = () => {},
onCancel = () => {},
}: Props = $props();
let query = $state("");
let caseSensitive = $state(false);
let limit = $state(250);
let searchedQuery = $state("");
let searched = $state(false);
function submit(event?: SubmitEvent) {
event?.preventDefault();
const value = query.trim();
if (!value || !hasRepository || isBusy || isSearching) return;
searched = true;
searchedQuery = value;
void onSearch(value, caseSensitive, limit);
}
function handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
submit();
}
}
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function displayPath(hit: GitSearchHit): string {
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file;
}
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global code search" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Global search</span>
<h2 class="dialog-title">Find where code was introduced</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<div class="global-search-body">
<form class="global-search-form" onsubmit={submit}>
<label class="global-search-query">
<span>String or function</span>
<textarea
bind:value={query}
onkeydown={handleKeydown}
disabled={!hasRepository || isBusy || isSearching}
spellcheck="false"
placeholder={"Paste a string, symbol, or full function body..."}
></textarea>
</label>
<div class="global-search-options">
<label class="check-row">
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
<span>Exact case</span>
</label>
<label class="search-limit">
<span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
{#if isSearching}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Search size={16} aria-hidden="true" />
{/if}
Search
</button>
{#if isSearching}
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
<X size={16} aria-hidden="true" />
Cancel
</button>
{/if}
</div>
</form>
<section class="global-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if isSearching}
<div class="blank-state">
<LoaderCircle class="spin" size={20} aria-hidden="true" />
Searching all branches...
</div>
{:else if error}
<div class="blank-state search-error">{error}</div>
{:else if !searched}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
{:else if results.length === 0}
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
{:else}
<div class="search-result-head">
<strong>{results.length}</strong>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<div class="search-hit-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
<article class="search-hit">
<header class="search-hit-top">
<span class="hash">{hit.short_hash}</span>
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
{#if hit.matches_added > 1}
<span class="pill pill-active">+{hit.matches_added} matches</span>
{/if}
</header>
<div class="search-hit-meta">
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
</div>
<div class="search-hit-file" title={displayPath(hit)}>
<FileCode size={14} aria-hidden="true" />
<span>{displayPath(hit)}</span>
{#if hit.line_number}
<strong>:{hit.line_number}</strong>
{/if}
</div>
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
</article>
{/each}
</div>
{/if}
</section>
</div>
</div>
</div>
+21
View File
@@ -6,6 +6,7 @@ import type {
GitCommit, GitCommit,
GitCommitComparison, GitCommitComparison,
GitRepositoryFile, GitRepositoryFile,
GitSearchHit,
GitStatus, GitStatus,
} from "./types"; } from "./types";
@@ -97,6 +98,26 @@ export function diffFileAgainstWorkingTree(
return invoke<GitCommitComparison>("diff_file_against_working_tree", { path, commit, file }); return invoke<GitCommitComparison>("diff_file_against_working_tree", { path, commit, file });
} }
export function searchCodeIntroductions(
path: string,
query: string,
caseSensitive = false,
limit = 250,
searchId?: string,
): Promise<GitSearchHit[]> {
return invoke<GitSearchHit[]>("search_code_introductions", {
path,
query,
caseSensitive,
limit,
searchId: searchId ?? null,
});
}
export function cancelCodeSearch(searchId: string): Promise<void> {
return invoke<void>("cancel_code_search", { searchId });
}
export function readConflict(path: string, file: string): Promise<ConflictFile> { export function readConflict(path: string, file: string): Promise<ConflictFile> {
return invoke<ConflictFile>("read_conflict", { path, file }); return invoke<ConflictFile>("read_conflict", { path, file });
} }
+14
View File
@@ -71,6 +71,20 @@ export interface GitCommitComparison {
patch: string; patch: string;
} }
export interface GitSearchHit {
commit_hash: string;
short_hash: string;
summary: string;
author_name: string;
author_email: string;
date: string;
file: string;
old_file: string | null;
line_number: number | null;
line: string;
matches_added: number;
}
export type ExplorerNodeKind = "folder" | "file"; export type ExplorerNodeKind = "folder" | "file";
export interface ExplorerNode { export interface ExplorerNode {