- Replaced hardcoded maximum lengths for comments and messages with a centralized constant `MAX_TEXT_LENGTH` for consistency across components. - Enhanced README to provide a brief overview of the project. - Introduced a new function to build a compact members roster for improved team member display in reminders. - Added support for displaying image attachments from comments in the task detail dialog.
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
/**
|
|
* FileLink — clickable file path link for markdown content.
|
|
* Opens the file in the built-in editor (team context) or copies the absolute path (session context).
|
|
*
|
|
* Follows the LocalImage pattern (MarkdownViewer.tsx) — a standalone React component
|
|
* used inside react-markdown's `a` component factory.
|
|
*/
|
|
|
|
import React from 'react';
|
|
|
|
import { PROSE_LINK } from '@renderer/constants/cssVariables';
|
|
import { useStore } from '@renderer/store';
|
|
import type { AppState } from '@renderer/store/types';
|
|
import { Check, FileCode } from 'lucide-react';
|
|
|
|
// =============================================================================
|
|
// Exported utilities
|
|
// =============================================================================
|
|
|
|
/** Parse "path:line" format (e.g. "src/foo.ts:42") */
|
|
export function parsePathWithLine(href: string): { filePath: string; line: number | null } {
|
|
let decoded: string;
|
|
try {
|
|
decoded = decodeURIComponent(href);
|
|
} catch {
|
|
decoded = href;
|
|
}
|
|
const match = decoded.match(/^(.+?):(\d+)$/);
|
|
if (match) return { filePath: match[1], line: parseInt(match[2], 10) };
|
|
return { filePath: decoded, line: null };
|
|
}
|
|
|
|
/** Check if a URL is relative (not a protocol, not a hash, not data/mailto) */
|
|
export function isRelativeUrl(url: string): boolean {
|
|
return (
|
|
!!url &&
|
|
!url.startsWith('#') &&
|
|
!url.includes('://') &&
|
|
!url.startsWith('data:') &&
|
|
!url.startsWith('mailto:')
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Internal helpers
|
|
// =============================================================================
|
|
|
|
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
|
const parts = `${baseDir}/${relativeSrc}`.split('/');
|
|
const resolved: string[] = [];
|
|
for (const part of parts) {
|
|
if (part === '.' || part === '') continue;
|
|
if (part === '..') {
|
|
resolved.pop();
|
|
} else {
|
|
resolved.push(part);
|
|
}
|
|
}
|
|
return '/' + resolved.join('/');
|
|
}
|
|
|
|
/** Project path based on active tab context (avoids stale cross-tab state) */
|
|
function selectContextProjectPath(s: AppState): string | null {
|
|
const activeTab = s.openTabs.find((t) => t.id === s.activeTabId);
|
|
if (!activeTab) return null;
|
|
|
|
switch (activeTab.type) {
|
|
case 'team':
|
|
return s.selectedTeamData?.config.projectPath ?? null;
|
|
case 'session':
|
|
return s.sessionDetail?.session?.projectPath ?? null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function selectIsTeamTab(s: AppState): boolean {
|
|
const activeTab = s.openTabs.find((t) => t.id === s.activeTabId);
|
|
return activeTab?.type === 'team';
|
|
}
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
interface FileLinkProps {
|
|
href: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const FileLink = React.memo(function FileLink({
|
|
href,
|
|
children,
|
|
}: FileLinkProps): React.ReactElement {
|
|
const projectPath = useStore(selectContextProjectPath);
|
|
const isTeamTab = useStore(selectIsTeamTab);
|
|
const [copied, setCopied] = React.useState(false);
|
|
|
|
if (!projectPath) {
|
|
return (
|
|
<span className="font-mono text-xs" style={{ color: PROSE_LINK }}>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const { filePath: relativePath, line } = parsePathWithLine(href);
|
|
const absolutePath = resolveRelativePath(relativePath, projectPath);
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (isTeamTab) {
|
|
const { revealFileInEditor, setPendingGoToLine } = useStore.getState();
|
|
if (line !== null) setPendingGoToLine(line);
|
|
revealFileInEditor(absolutePath);
|
|
} else {
|
|
void navigator.clipboard.writeText(absolutePath).then(
|
|
() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
},
|
|
() => {
|
|
// Clipboard API may not be available in all contexts
|
|
}
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={handleClick}
|
|
className="inline-flex cursor-pointer items-center gap-0.5 rounded-sm px-0.5 no-underline hover:underline"
|
|
style={{
|
|
color: PROSE_LINK,
|
|
backgroundColor: 'var(--path-highlight-bg)',
|
|
}}
|
|
title={isTeamTab ? absolutePath : `Click to copy: ${absolutePath}`}
|
|
>
|
|
<FileCode size={12} className="shrink-0 opacity-60" />
|
|
{children}
|
|
{copied && <Check size={10} className="shrink-0 text-green-400" />}
|
|
</a>
|
|
);
|
|
});
|