add new style and global search
This commit is contained in:
Generated
+10
@@ -11,6 +11,7 @@
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"simple-icons": "^16.24.1",
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
@@ -1427,6 +1428,15 @@
|
||||
"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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"simple-icons": "^16.24.1",
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
|
||||
@@ -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)`);
|
||||
Generated
+159
-1
@@ -1932,6 +1932,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2396,6 +2397,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3040,6 +3065,64 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3147,6 +3230,7 @@ dependencies = [
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4046,6 +4130,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4079,13 +4172,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 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_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 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]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -4116,6 +4226,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4128,6 +4244,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4140,12 +4262,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4158,6 +4292,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4170,6 +4310,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -4182,6 +4328,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4194,6 +4346,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
|
||||
@@ -9,6 +9,7 @@ build = "build.rs"
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "=2.7.0"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-close",
|
||||
"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
@@ -1,9 +1,15 @@
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
ffi::{OsStr, OsString},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
process::{Command, Stdio},
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
@@ -102,6 +108,21 @@ pub struct GitRepositoryFile {
|
||||
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)]
|
||||
struct BranchInfo {
|
||||
current_branch: Option<String>,
|
||||
@@ -118,6 +139,54 @@ enum CheckoutPlan {
|
||||
}
|
||||
|
||||
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]
|
||||
pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
||||
@@ -394,6 +463,174 @@ pub fn list_file_history(
|
||||
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]
|
||||
pub fn restore_to_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||
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> {
|
||||
const FIELD_SEPARATOR: char = '\x1f';
|
||||
const RECORD_SEPARATOR: char = '\x1e';
|
||||
@@ -1283,6 +1779,20 @@ fn run_git_with_paths(
|
||||
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>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
@@ -1291,6 +1801,91 @@ where
|
||||
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>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
@@ -1565,6 +2160,89 @@ mod tests {
|
||||
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]
|
||||
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";
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
mod git;
|
||||
|
||||
use git::{
|
||||
checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status,
|
||||
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
||||
open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_to_commit, stage_files, unstage_files,
|
||||
cancel_code_search, checkout_branch, commit, compare_commits, diff_file_against_working_tree,
|
||||
get_status, list_branches, list_commits, list_file_history, list_repository_files,
|
||||
merge_branch, open_repository, pull, push, read_conflict, resolve_conflict,
|
||||
resolve_conflict_side, restore_file_from_commit, restore_files, restore_to_commit,
|
||||
search_code_introductions, stage_files, unstage_files, SearchCancellationState,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.manage(SearchCancellationState::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
get_status,
|
||||
@@ -30,6 +33,8 @@ fn main() {
|
||||
list_file_history,
|
||||
compare_commits,
|
||||
diff_file_against_working_tree,
|
||||
search_code_introductions,
|
||||
cancel_code_search,
|
||||
read_conflict,
|
||||
resolve_conflict,
|
||||
resolve_conflict_side
|
||||
|
||||
+102
-4
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||
@@ -10,6 +11,7 @@
|
||||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||
@@ -18,6 +20,7 @@
|
||||
checkoutBranch,
|
||||
commit,
|
||||
compareCommits,
|
||||
cancelCodeSearch,
|
||||
diffFileAgainstWorkingTree,
|
||||
getStatus,
|
||||
listBranches,
|
||||
@@ -34,6 +37,7 @@
|
||||
restoreFileFromCommit,
|
||||
restoreFiles,
|
||||
restoreToCommit,
|
||||
searchCodeIntroductions,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
} from "./lib/git";
|
||||
@@ -49,6 +53,7 @@
|
||||
GitDiffFile,
|
||||
GitFileStatus,
|
||||
GitRepositoryFile,
|
||||
GitSearchHit,
|
||||
GitStatus,
|
||||
PreparedResolution,
|
||||
} from "./lib/types";
|
||||
@@ -74,6 +79,11 @@
|
||||
let comparison: GitCommitComparison | null = null;
|
||||
let compareDialogOpen = false;
|
||||
let selectedDiffPath = "";
|
||||
let globalSearchOpen = false;
|
||||
let globalSearchResults: GitSearchHit[] = [];
|
||||
let globalSearchBusy = false;
|
||||
let globalSearchError = "";
|
||||
let globalSearchId = "";
|
||||
let resolveDialogOpen = false;
|
||||
let conflictTarget = "";
|
||||
let conflict: ConflictFile | null = null;
|
||||
@@ -122,7 +132,7 @@
|
||||
}
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen) return;
|
||||
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || globalSearchOpen) return;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
const nextStatus = await getStatus(activeRepoPath);
|
||||
@@ -220,9 +230,10 @@
|
||||
|
||||
// ── Repository operations ──────────────────────────────────────────────────
|
||||
|
||||
async function openRepo() {
|
||||
const path = repoPath.trim();
|
||||
async function openRepo(pathOverride?: string) {
|
||||
const path = (pathOverride ?? repoPath).trim();
|
||||
if (!path) { errorMessage = "Enter a repository path."; return; }
|
||||
repoPath = path;
|
||||
|
||||
await runOperation("Opening repository", async () => {
|
||||
const nextStatus = await openRepository(path);
|
||||
@@ -232,6 +243,8 @@
|
||||
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
|
||||
fileHistory = []; compareFrom = ""; compareTo = "";
|
||||
comparison = null; compareDialogOpen = false; selectedDiffPath = "";
|
||||
if (globalSearchBusy) void cancelGlobalSearch();
|
||||
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
|
||||
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
|
||||
preparedResolutions = {};
|
||||
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() {
|
||||
if (!activeRepoPath) { await openRepo(); return; }
|
||||
await runOperation("Refreshing", async () => {
|
||||
@@ -474,6 +504,48 @@
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
async function loadConflict(file: string) {
|
||||
@@ -543,6 +615,7 @@
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && compareDialogOpen) compareDialogOpen = false;
|
||||
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -566,6 +639,7 @@
|
||||
onPull={pullRepo}
|
||||
onPush={pushRepo}
|
||||
onRefresh={refreshRepo}
|
||||
onSearch={() => { globalSearchOpen = true; }}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
/>
|
||||
|
||||
@@ -583,6 +657,17 @@
|
||||
placeholder="/path/to/repository"
|
||||
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}>
|
||||
{#if operation === "Opening repository"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
@@ -743,6 +828,19 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if globalSearchOpen}
|
||||
<GlobalSearchDialog
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
isSearching={globalSearchBusy}
|
||||
error={globalSearchError}
|
||||
results={globalSearchResults}
|
||||
onClose={closeGlobalSearchDialog}
|
||||
onSearch={runGlobalSearch}
|
||||
onCancel={cancelGlobalSearch}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Credential dialog for push/pull -->
|
||||
{#if credDialogOpen && credDialogAction}
|
||||
<CredentialDialog
|
||||
|
||||
+286
-50
@@ -1,29 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ink: #dde1f0;
|
||||
--color-ink-muted: #8090be;
|
||||
--color-ink-faint: #3c4668;
|
||||
--color-ink-dim: #5e6e9c;
|
||||
--color-ink-quiet: #7880ac;
|
||||
--color-ink: #f5f7ff;
|
||||
--color-ink-muted: #a8b1d8;
|
||||
--color-ink-faint: #687197;
|
||||
--color-ink-dim: #838cb8;
|
||||
--color-ink-quiet: #949bc8;
|
||||
|
||||
--color-surface: #161a24;
|
||||
--color-surface-alt: #0e1118;
|
||||
--color-surface-dim: #191d2a;
|
||||
--color-surface-hover: #202438;
|
||||
--color-surface-raised: #1c2033;
|
||||
--color-surface: rgba(22, 22, 36, 0.82);
|
||||
--color-surface-alt: #0b0b14;
|
||||
--color-surface-dim: rgba(18, 18, 30, 0.9);
|
||||
--color-surface-hover: rgba(47, 48, 78, 0.76);
|
||||
--color-surface-raised: rgba(28, 29, 48, 0.88);
|
||||
|
||||
--color-border: #2a2f45;
|
||||
--color-border-subtle: #1e2236;
|
||||
--color-border-input: #323658;
|
||||
--color-border: rgba(100, 108, 255, 0.28);
|
||||
--color-border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--color-border-input: rgba(65, 209, 255, 0.26);
|
||||
|
||||
--color-primary: #5a8cf8;
|
||||
--color-primary-dark: #4878f0;
|
||||
--color-accent: #6a9aff;
|
||||
--color-primary: #646cff;
|
||||
--color-primary-dark: #535bf2;
|
||||
--color-accent: #41d1ff;
|
||||
|
||||
--color-bar: #090c14;
|
||||
--color-bar-text: #c0cce0;
|
||||
--color-bar-muted: #4a5a78;
|
||||
--color-bar: rgba(9, 9, 18, 0.92);
|
||||
--color-bar-text: #f4f6ff;
|
||||
--color-bar-muted: #7b84b2;
|
||||
|
||||
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
@@ -43,7 +43,11 @@
|
||||
body {
|
||||
overflow: hidden;
|
||||
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-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -61,9 +65,9 @@
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
font-size: 13px;
|
||||
@@ -75,20 +79,21 @@
|
||||
}
|
||||
button:disabled { cursor: not-allowed; opacity: 0.4; }
|
||||
|
||||
input, textarea {
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-ink);
|
||||
background: var(--color-surface-raised);
|
||||
background: rgba(13, 14, 26, 0.78);
|
||||
outline: none;
|
||||
}
|
||||
input:focus, textarea:focus {
|
||||
input:focus, textarea:focus, select:focus {
|
||||
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; }
|
||||
textarea { resize: vertical; min-height: 132px; padding: 10px 11px; line-height: 1.45; }
|
||||
select { height: 34px; padding: 0 9px; }
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
@@ -120,22 +125,40 @@
|
||||
.btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; }
|
||||
|
||||
.btn-primary {
|
||||
border-color: var(--color-primary);
|
||||
border-color: rgba(100, 108, 255, 0.75);
|
||||
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;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
border-color: var(--color-primary-dark);
|
||||
background: var(--color-primary-dark);
|
||||
border-color: rgba(65, 209, 255, 0.82);
|
||||
background: linear-gradient(135deg, #747bff 0%, #c966ff 100%);
|
||||
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 {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
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 {
|
||||
@@ -146,6 +169,7 @@
|
||||
min-height: 46px;
|
||||
padding: 8px 12px;
|
||||
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; }
|
||||
|
||||
@@ -209,14 +233,14 @@
|
||||
|
||||
/* --- 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0 10px 10px;
|
||||
padding: 8px 12px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -226,8 +250,10 @@
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
background: var(--color-bar);
|
||||
height: 42px;
|
||||
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);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
@@ -246,7 +272,7 @@
|
||||
color: var(--color-bar-text);
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -257,17 +283,17 @@
|
||||
height: 100%;
|
||||
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-sep { color: #2a3440; 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-repo { color: #8e95c9; font-size: 12px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; }
|
||||
.tb-sep { color: rgba(255,255,255,0.22); font-size: 13px; }
|
||||
.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.ahead { color: #e0a040; background: rgba(224,160,64,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-actions { display: flex; align-items: stretch; }
|
||||
@@ -319,15 +345,25 @@
|
||||
|
||||
/* --- 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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
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-browse { min-width: 104px; }
|
||||
|
||||
/* --- Notices --- */
|
||||
|
||||
@@ -367,6 +403,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) clamp(400px, 40vw, 620px);
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -374,6 +411,7 @@
|
||||
.history-aside {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -381,6 +419,7 @@
|
||||
.left-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(200px, 0.9fr) minmax(240px, 1.1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -390,6 +429,7 @@
|
||||
.main-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
@@ -619,9 +659,9 @@
|
||||
|
||||
/* --- 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:hover { border-color: var(--color-border); }
|
||||
.commit-row.compact { padding: 8px; }
|
||||
@@ -629,6 +669,7 @@
|
||||
.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 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 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 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 time { 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-actions { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.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; 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 --- */
|
||||
|
||||
@@ -739,6 +811,10 @@
|
||||
width: min(1560px, calc(100vw - 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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
.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 --- */
|
||||
|
||||
.cred-card {
|
||||
@@ -1415,6 +1650,7 @@
|
||||
.compare-arrow { display: none; }
|
||||
.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); }
|
||||
.branch-actions { flex-direction: row; justify-content: flex-start; }
|
||||
.tb-action-label { display: none; }
|
||||
|
||||
+13
-1
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
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 ahead: number = 0;
|
||||
@@ -15,6 +15,7 @@
|
||||
export let onPull: () => void = () => {};
|
||||
export let onPush: () => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onSearch: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
|
||||
const win = getCurrentWindow();
|
||||
@@ -80,6 +81,17 @@
|
||||
<!-- Right: actions + window controls -->
|
||||
<div class="titlebar-right">
|
||||
<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
|
||||
class="tb-action"
|
||||
onclick={onPull}
|
||||
|
||||
@@ -74,15 +74,15 @@
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Selected file history">
|
||||
<div class="section-head">
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<div class="section-head file-history-head">
|
||||
<div class="file-history-heading">
|
||||
<span class="eyebrow">{selectedExplorerLabel}</span>
|
||||
<h2
|
||||
class="mt-0.5 text-ink text-base font-bold leading-tight truncate"
|
||||
class="file-history-name"
|
||||
use:pathTooltip={selectedExplorerPath}
|
||||
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
|
||||
</div>
|
||||
<span class="pill pill-count flex-shrink-0">{fileHistory.length}</span>
|
||||
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
@@ -94,16 +94,16 @@
|
||||
{:else}
|
||||
<div class="history-list overflow-auto p-2">
|
||||
{#each fileHistory as item (item.hash)}
|
||||
<article class="commit-row compact">
|
||||
<article class="commit-row compact file-history-row">
|
||||
<div class="commit-line">
|
||||
<History size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<div class="commit-line-text">
|
||||
<strong title={item.summary}>{item.summary}</strong>
|
||||
<span>{item.short_hash} - {item.author_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commit-actions">
|
||||
<div class="commit-actions file-history-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
<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>
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitRepositoryFile,
|
||||
GitSearchHit,
|
||||
GitStatus,
|
||||
} from "./types";
|
||||
|
||||
@@ -97,6 +98,26 @@ export function diffFileAgainstWorkingTree(
|
||||
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> {
|
||||
return invoke<ConflictFile>("read_conflict", { path, file });
|
||||
}
|
||||
|
||||
@@ -71,6 +71,20 @@ export interface GitCommitComparison {
|
||||
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 interface ExplorerNode {
|
||||
|
||||
Reference in New Issue
Block a user