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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 122 additions & 1 deletion cardinal/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use search_cache::{
};
use search_cancel::CancellationToken;
use serde::{Deserialize, Serialize};
use std::{cell::LazyCell, process::Command};
use std::{cell::LazyCell, fs::File, io::Read, process::Command};
use tauri::{ActivationPolicy, AppHandle, State};
use tracing::{error, info, warn};

Expand Down Expand Up @@ -217,6 +217,8 @@ pub struct NodeInfo {
pub path: String,
pub metadata: Option<NodeInfoMetadata>,
pub icon: Option<String>,
#[serde(rename = "contentContext")]
pub content_context: Option<String>,
}

#[derive(Serialize, Default)]
Expand Down Expand Up @@ -335,19 +337,33 @@ pub async fn search(
pub fn get_nodes_info(
results: Vec<SlabIndex>,
include_icons: Option<bool>,
content_terms: Option<Vec<String>>,
case_insensitive: Option<bool>,
state: State<'_, SearchState>,
) -> Vec<NodeInfo> {
if results.is_empty() {
return Vec::new();
}

let include_icons = include_icons.unwrap_or(true);
let content_terms = content_terms
.unwrap_or_default()
.into_iter()
.map(|term| term.trim().to_string())
.filter(|term| !term.is_empty())
.collect::<Vec<_>>();
let case_insensitive = case_insensitive.unwrap_or_default();
let nodes = state.request_nodes(results);

nodes
.into_iter()
.map(|SearchResultNode { path, metadata }| {
let path = path.to_string_lossy().into_owned();
let content_context = if content_terms.is_empty() {
None
} else {
content_context_for_path(&path, &content_terms, case_insensitive)
};
let icon = if include_icons {
fs_icon::icon_of_path_ns(&path).map(|data| {
format!(
Expand All @@ -362,6 +378,7 @@ pub fn get_nodes_info(
path,
icon,
metadata: metadata.as_ref().map(NodeInfoMetadata::from_metadata),
content_context,
}
})
.collect()
Expand Down Expand Up @@ -397,6 +414,110 @@ pub fn update_icon_viewport(id: u64, viewport: Vec<SlabIndex>, state: State<'_,
}
}

const CONTENT_CONTEXT_BEFORE_BYTES: usize = 24;
const CONTENT_CONTEXT_AFTER_BYTES: usize = 160;
const CONTENT_SNIPPET_BUFFER_BYTES: usize = 64 * 1024;

fn content_context_for_path(
path: &str,
content_terms: &[String],
case_insensitive: bool,
) -> Option<String> {
content_terms
.iter()
.find_map(|term| content_context_for_term(path, term, case_insensitive))
}

fn content_context_for_term(path: &str, term: &str, case_insensitive: bool) -> Option<String> {
let needle = if case_insensitive {
term.to_ascii_lowercase().into_bytes()
} else {
term.as_bytes().to_vec()
};
if needle.is_empty() {
return None;
}

let mut file = File::open(path).ok()?;
let max_context_bytes = CONTENT_CONTEXT_BEFORE_BYTES.max(CONTENT_CONTEXT_AFTER_BYTES);
let overlap = needle.len().saturating_sub(1).max(max_context_bytes);
let mut buffer = vec![0u8; CONTENT_SNIPPET_BUFFER_BYTES + overlap];
let mut carry_len = 0usize;
let mut consumed_bytes = 0usize;

loop {
let read = file.read(&mut buffer[carry_len..]).ok()?;
if read == 0 {
return None;
}

let chunk_len = carry_len + read;
let chunk = &buffer[..chunk_len];
let mut searchable;
let haystack = if case_insensitive {
searchable = chunk.to_vec();
searchable.make_ascii_lowercase();
searchable.as_slice()
} else {
chunk
};

if let Some(match_index) = find_bytes(haystack, &needle) {
let before_start = match_index.saturating_sub(CONTENT_CONTEXT_BEFORE_BYTES);
let after_end =
(match_index + needle.len() + CONTENT_CONTEXT_AFTER_BYTES).min(chunk_len);
let mut snippet = chunk[before_start..after_end].to_vec();

let desired_after = match_index + needle.len() + CONTENT_CONTEXT_AFTER_BYTES;
let mut has_suffix = after_end < chunk_len;
if desired_after > chunk_len {
let mut extra = vec![0u8; desired_after - chunk_len];
if let Ok(extra_read) = file.read(&mut extra) {
snippet.extend_from_slice(&extra[..extra_read]);
has_suffix = extra_read == extra.len();
}
}
Comment on lines +471 to +479

let chunk_start = consumed_bytes.saturating_sub(carry_len);
let absolute_match_index = chunk_start + match_index;
let has_prefix = absolute_match_index > CONTENT_CONTEXT_BEFORE_BYTES;
return Some(format_context_snippet(snippet, has_prefix, has_suffix));
}

let keep = overlap.min(chunk_len);
if keep > 0 {
let start = chunk_len - keep;
buffer.copy_within(start..chunk_len, 0);
}
consumed_bytes += read;
carry_len = keep;
}
}

fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}

fn format_context_snippet(snippet: Vec<u8>, has_prefix: bool, has_suffix: bool) -> String {
let normalized = String::from_utf8_lossy(&snippet)
.chars()
.map(|ch| match ch {
'\r' | '\n' | '\t' => ' ',
_ => ch,
})
.collect::<String>();
let compact = normalized.split_whitespace().collect::<Vec<_>>().join(" ");

match (has_prefix, has_suffix) {
(true, true) => format!("...{compact}..."),
(true, false) => format!("...{compact}"),
(false, true) => format!("{compact}..."),
(false, false) => compact,
}
}

#[tauri::command]
pub async fn get_app_status() -> String {
load_app_state().as_str().to_string()
Expand Down
26 changes: 26 additions & 0 deletions cardinal/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ button:active:not(:disabled) {
--columns-total: calc(
var(--w-filename) + var(--w-path) + var(--w-size) + var(--w-modified) + var(--w-created)
);
--columns-total-with-context: calc(var(--columns-total) + var(--w-context));
}

.scroll-area {
Expand Down Expand Up @@ -597,6 +598,14 @@ button:active:not(:disabled) {
min-width: var(--columns-total);
}

.scroll-area--with-context .columns {
grid-template-columns:
var(--w-context) var(--w-filename) var(--w-path) var(--w-size) var(--w-modified)
var(--w-created);
width: var(--columns-total-with-context);
min-width: var(--columns-total-with-context);
}

/* === Header Row === */
.header-row-container {
overflow: hidden;
Expand Down Expand Up @@ -628,6 +637,16 @@ button:active:not(:disabled) {
var(--virtual-scrollbar-width);
}

.scroll-area--with-context .header-row {
width: calc(var(--columns-total-with-context) + var(--virtual-scrollbar-width));
}

.scroll-area--with-context .header-row.columns {
grid-template-columns:
var(--w-context) var(--w-filename) var(--w-path) var(--w-size) var(--w-modified)
var(--w-created) var(--virtual-scrollbar-width);
}

.header {
font-weight: 600;
color: var(--color-header);
Expand Down Expand Up @@ -933,6 +952,13 @@ button:active:not(:disabled) {
padding: 0 var(--cell-hpad);
}

.context-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 var(--cell-hpad);
}

.filename-text {
flex: 1;
min-width: 0;
Expand Down
19 changes: 17 additions & 2 deletions cardinal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useAppPreferences } from './hooks/useAppPreferences';
import { useAppWindowListeners } from './hooks/useAppWindowListeners';
import { useFilesTabEffects } from './hooks/useFilesTabEffects';
import { useFilesTabState } from './hooks/useFilesTabState';
import { extractContentTerms } from './utils/contentQuery';

function App() {
const {
Expand Down Expand Up @@ -262,6 +263,11 @@ function App() {
}, []);

const selectedIndexSet = useMemo(() => new Set(selectedIndices), [selectedIndices]);
const contentTerms = useMemo(() => extractContentTerms(currentQuery), [currentQuery]);
const showContentContext = contentTerms.length > 0;
const fileRowsWidth = showContentContext
? 'var(--columns-total-with-context)'
: 'var(--columns-total)';

const handleRowContextMenu = useCallback(
(event: ReactMouseEvent<HTMLDivElement>, path: string, rowIndex: number) => {
Expand Down Expand Up @@ -289,7 +295,7 @@ function App() {
<div
key={`placeholder-${rowIndex}`}
className="row columns row-loading"
style={{ ...rowStyle, width: 'var(--columns-total)' }}
style={{ ...rowStyle, width: fileRowsWidth }}
/>
);
}
Expand All @@ -299,11 +305,13 @@ function App() {
key={item.path}
rowIndex={rowIndex}
item={item}
style={{ ...rowStyle, width: 'var(--columns-total)' }}
style={{ ...rowStyle, width: fileRowsWidth }}
isSelected={selectedIndexSet.has(rowIndex)}
selectedPathsForDrag={selectedPaths}
caseInsensitive={!caseSensitive}
highlightTerms={highlightTerms}
contentTerms={contentTerms}
showContentContext={showContentContext}
onContextMenu={handleRowContextMenu}
onSelect={handleRowSelect}
onOpen={openResultPath}
Expand All @@ -314,6 +322,9 @@ function App() {
handleRowContextMenu,
handleRowSelect,
highlightTerms,
contentTerms,
showContentContext,
fileRowsWidth,
caseSensitive,
selectedIndexSet,
selectedPaths,
Expand All @@ -335,6 +346,7 @@ function App() {
({
'--w-filename': `${colWidths.filename}px`,
'--w-path': `${colWidths.path}px`,
'--w-context': `${Math.max(420, Math.floor(window.innerWidth * 0.4))}px`,
'--w-size': `${colWidths.size}px`,
'--w-modified': `${colWidths.modified}px`,
'--w-created': `${colWidths.created}px`,
Expand Down Expand Up @@ -426,6 +438,9 @@ function App() {
onSortToggle={handleSortToggle}
sortDisabled={sortButtonsDisabled}
sortDisabledTooltip={sortDisabledTooltip}
showContentContext={showContentContext}
contentTerms={contentTerms}
caseInsensitive={!caseSensitive}
/>
)}
</div>
Expand Down
14 changes: 13 additions & 1 deletion cardinal/src/components/ColumnHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,30 @@ type ColumnHeaderProps = {
onSortToggle: (sortKey: SortKey) => void;
sortDisabled: boolean;
sortDisabledTooltip: string | null;
showContentContext: boolean;
};

// Column widths are applied via CSS vars on container; no need to pass colWidths prop.
export const ColumnHeader = forwardRef<HTMLDivElement, ColumnHeaderProps>(
(
{ onResizeStart, onContextMenu, sortState, onSortToggle, sortDisabled, sortDisabledTooltip },
{
onResizeStart,
onContextMenu,
sortState,
onSortToggle,
sortDisabled,
sortDisabledTooltip,
showContentContext,
},
ref,
) => {
const { t } = useTranslation();
return (
<div ref={ref} className="header-row-container">
<div className="header-row columns" onContextMenu={onContextMenu}>
{showContentContext ? (
<span className="context-text header header-cell">{t('columns.context')}</span>
) : null}
{columns.map(({ key, labelKey, className }) => {
const label = t(labelKey);
const sortKey = sortableColumns[key];
Expand Down
17 changes: 17 additions & 0 deletions cardinal/src/components/FileRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type FileRowProps = {
selectedPathsForDrag?: string[];
caseInsensitive?: boolean;
highlightTerms?: readonly string[];
contentTerms?: readonly string[];
showContentContext?: boolean;
};

export const FileRow = memo(function FileRow({
Expand All @@ -34,6 +36,8 @@ export const FileRow = memo(function FileRow({
selectedPathsForDrag = [],
caseInsensitive,
highlightTerms,
contentTerms,
showContentContext = false,
}: FileRowProps): React.JSX.Element {
const pendingSelectRef = useRef<{
isShift: boolean;
Expand Down Expand Up @@ -148,6 +152,19 @@ export const FileRow = memo(function FileRow({
aria-selected={isSelected}
title={path}
>
{showContentContext ? (
item.contentContext ? (
<MiddleEllipsisHighlight
className="context-text"
text={item.contentContext}
highlightTerms={contentTerms}
caseInsensitive={caseInsensitive}
ellipsisMode="end"
/>
) : (
<span className="context-text muted">—</span>
)
) : null}
<div className="filename-column">
{item.icon ? (
<img src={item.icon} alt="icon" className="file-icon" />
Expand Down
Loading
Loading