mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-06-12 01:14:27 +02:00
Handle external files
This commit is contained in:
@@ -5,7 +5,7 @@ export type GitAuthor = { name: string | null, email: string | null, };
|
|||||||
|
|
||||||
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
||||||
|
|
||||||
export type GitStatus = "added" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
|
export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "removed" | "renamed" | "type_change";
|
||||||
|
|
||||||
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
|
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub struct GitStatusEntry {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
pub enum GitStatus {
|
pub enum GitStatus {
|
||||||
Added,
|
Untracked,
|
||||||
Conflict,
|
Conflict,
|
||||||
Current,
|
Current,
|
||||||
Modified,
|
Modified,
|
||||||
@@ -217,7 +217,7 @@ pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
|
|||||||
let index_status = match status {
|
let index_status = match status {
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Added,
|
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
||||||
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
||||||
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
||||||
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
||||||
@@ -232,7 +232,7 @@ pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
|
|||||||
let worktree_status = match status {
|
let worktree_status = match status {
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
s if s.contains(git2::Status::WT_NEW) => GitStatus::Added,
|
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
||||||
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
||||||
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
||||||
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::error::Error::{InvalidSyncFile, UnknownModel};
|
use crate::error::Error::UnknownModel;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -23,26 +23,29 @@ pub enum SyncModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SyncModel {
|
impl SyncModel {
|
||||||
pub fn from_bytes(
|
pub fn from_bytes(content: Vec<u8>, file_path: &Path) -> Result<Option<(SyncModel, String)>> {
|
||||||
content: Vec<u8>,
|
|
||||||
file_path: &Path,
|
|
||||||
) -> Result<Option<(SyncModel, Vec<u8>, String)>> {
|
|
||||||
let mut hasher = Sha1::new();
|
let mut hasher = Sha1::new();
|
||||||
hasher.update(&content);
|
hasher.update(&content);
|
||||||
let checksum = hex::encode(hasher.finalize());
|
let checksum = hex::encode(hasher.finalize());
|
||||||
|
let content_str = String::from_utf8(content.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
// Check for some strings that will be in a model file for sure. If these strings
|
||||||
|
// don't exist, then it's probably not a Yaak file.
|
||||||
|
if !content_str.contains("model") || !content_str.contains("id") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
let ext = file_path.extension().unwrap_or_default();
|
let ext = file_path.extension().unwrap_or_default();
|
||||||
if ext == "yml" || ext == "yaml" {
|
if ext == "yml" || ext == "yaml" {
|
||||||
Ok(Some((serde_yaml::from_slice(content.as_slice())?, content, checksum)))
|
Ok(Some((serde_yaml::from_str(&content_str)?, checksum)))
|
||||||
} else if ext == "json" {
|
} else if ext == "json" {
|
||||||
Ok(Some((serde_json::from_reader(content.as_slice())?, content, checksum)))
|
Ok(Some((serde_json::from_str(&content_str)?, checksum)))
|
||||||
} else {
|
} else {
|
||||||
let p = file_path.to_str().unwrap().to_string();
|
Ok(None)
|
||||||
Err(InvalidSyncFile(format!("Unknown file extension {p}")))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_file(file_path: &Path) -> Result<Option<(SyncModel, Vec<u8>, String)>> {
|
pub fn from_file(file_path: &Path) -> Result<Option<(SyncModel, String)>> {
|
||||||
let content = match fs::read(file_path) {
|
let content = match fs::read(file_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return Ok(None),
|
Err(_) => return Ok(None),
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ pub(crate) async fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let path = dir_entry.path();
|
let path = dir_entry.path();
|
||||||
let (model, _, checksum) = match SyncModel::from_file(&path) {
|
let (model, checksum) = match SyncModel::from_file(&path) {
|
||||||
Ok(Some(m)) => m,
|
Ok(Some(m)) => m,
|
||||||
Ok(None) => continue,
|
Ok(None) => continue,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Checkbox } from './core/Checkbox';
|
|||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
import { InlineCode } from './core/InlineCode';
|
import { InlineCode } from './core/InlineCode';
|
||||||
import { Input } from './core/Input';
|
import { Input } from './core/Input';
|
||||||
|
import { Separator } from './core/Separator';
|
||||||
import { SplitLayout } from './core/SplitLayout';
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
import { HStack } from './core/Stacks';
|
import { HStack } from './core/Stacks';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
@@ -53,18 +54,27 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
onDone();
|
onDone();
|
||||||
};
|
};
|
||||||
|
|
||||||
const entries = status.data?.entries ?? null;
|
const { internalEntries, externalEntries, allEntries } = useMemo(() => {
|
||||||
|
const allEntries = [];
|
||||||
|
const yaakEntries = [];
|
||||||
|
const externalEntries = [];
|
||||||
|
for (const entry of status.data?.entries ?? []) {
|
||||||
|
allEntries.push(entry);
|
||||||
|
if (entry.next == null && entry.prev == null) {
|
||||||
|
externalEntries.push(entry);
|
||||||
|
} else {
|
||||||
|
yaakEntries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { internalEntries: yaakEntries, externalEntries, allEntries };
|
||||||
|
}, [status.data?.entries]);
|
||||||
|
|
||||||
const hasAddedAnything = entries?.find((s) => s.staged) != null;
|
const hasAddedAnything = allEntries.find((e) => e.staged) != null;
|
||||||
const hasAnythingToAdd = entries?.find((s) => s.status !== 'current') != null;
|
const hasAnythingToAdd = allEntries.find((e) => e.status !== 'current') != null;
|
||||||
|
|
||||||
const tree: TreeNode | null = useMemo(() => {
|
const tree: TreeNode | null = useMemo(() => {
|
||||||
if (entries == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = (model: TreeNode['model'], ancestors: TreeNode[]): TreeNode | null => {
|
const next = (model: TreeNode['model'], ancestors: TreeNode[]): TreeNode | null => {
|
||||||
const statusEntry = entries?.find((s) => s.relaPath.includes(model.id));
|
const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));
|
||||||
if (statusEntry == null) {
|
if (statusEntry == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -76,9 +86,11 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
ancestors,
|
ancestors,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of internalEntries) {
|
||||||
const childModel = entry.next ?? entry.prev;
|
const childModel = entry.next ?? entry.prev;
|
||||||
if (childModel == null) return null; // TODO: Is this right?
|
|
||||||
|
// Should never happen because we're iterating internalEntries
|
||||||
|
if (childModel == null) continue;
|
||||||
|
|
||||||
// TODO: Figure out why not all of these show up
|
// TODO: Figure out why not all of these show up
|
||||||
if ('folderId' in childModel && childModel.folderId != null) {
|
if ('folderId' in childModel && childModel.folderId != null) {
|
||||||
@@ -96,8 +108,9 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
|
|
||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
return next(workspace, []);
|
return next(workspace, []);
|
||||||
}, [entries, workspace]);
|
}, [workspace, internalEntries]);
|
||||||
|
|
||||||
if (tree == null) {
|
if (tree == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -114,6 +127,11 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
// TODO: Also ensure parents are added properly
|
// TODO: Also ensure parents are added properly
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkEntry = (entry: GitStatusEntry) => {
|
||||||
|
if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });
|
||||||
|
else add.mutate({ relaPaths: [entry.relaPath] });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-1 h-full">
|
<div className="grid grid-rows-1 h-full">
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
@@ -123,6 +141,16 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
firstSlot={({ style }) => (
|
firstSlot={({ style }) => (
|
||||||
<div style={style} className="h-full overflow-y-auto -ml-1 pb-3">
|
<div style={style} className="h-full overflow-y-auto -ml-1 pb-3">
|
||||||
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
|
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
|
||||||
|
{externalEntries.length > 0 && (
|
||||||
|
<Separator className="mt-3 mb-1">External file changes</Separator>
|
||||||
|
)}
|
||||||
|
{externalEntries.map((entry) => (
|
||||||
|
<ExternalTreeNode
|
||||||
|
key={entry.relaPath + entry.status}
|
||||||
|
entry={entry}
|
||||||
|
onCheck={checkEntry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
secondSlot={({ style }) => (
|
secondSlot={({ style }) => (
|
||||||
@@ -211,17 +239,13 @@ function TreeNodeChildren({
|
|||||||
) : (
|
) : (
|
||||||
<span aria-hidden />
|
<span aria-hidden />
|
||||||
)}
|
)}
|
||||||
<div className="truncate">
|
<div className="truncate">{fallbackRequestName(node.model)}</div>
|
||||||
{fallbackRequestName(node.model)}
|
|
||||||
{/*({node.model.model})*/}
|
|
||||||
{/*({node.status.staged ? 'Y' : 'N'})*/}
|
|
||||||
</div>
|
|
||||||
{node.status.status !== 'current' && (
|
{node.status.status !== 'current' && (
|
||||||
<InlineCode
|
<InlineCode
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'py-0 ml-auto bg-transparent w-[6rem] text-center',
|
'py-0 ml-auto bg-transparent w-[6rem] text-center',
|
||||||
node.status.status === 'modified' && 'text-info',
|
node.status.status === 'modified' && 'text-info',
|
||||||
node.status.status === 'added' && 'text-success',
|
node.status.status === 'untracked' && 'text-success',
|
||||||
node.status.status === 'removed' && 'text-danger',
|
node.status.status === 'removed' && 'text-danger',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -247,6 +271,41 @@ function TreeNodeChildren({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExternalTreeNode({
|
||||||
|
entry,
|
||||||
|
onCheck,
|
||||||
|
}: {
|
||||||
|
entry: GitStatusEntry;
|
||||||
|
onCheck: (entry: GitStatusEntry) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
fullWidth
|
||||||
|
className="h-xs w-full hover:bg-surface-highlight rounded px-1 group"
|
||||||
|
checked={entry.staged}
|
||||||
|
onChange={() => onCheck(entry)}
|
||||||
|
title={
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||||
|
<Icon color="secondary" icon="file_code" />
|
||||||
|
<div className="truncate">{entry.relaPath}</div>
|
||||||
|
{entry.status !== 'current' && (
|
||||||
|
<InlineCode
|
||||||
|
className={classNames(
|
||||||
|
'py-0 ml-auto bg-transparent w-[6rem] text-center',
|
||||||
|
entry.status === 'modified' && 'text-info',
|
||||||
|
entry.status === 'untracked' && 'text-success',
|
||||||
|
entry.status === 'removed' && 'text-danger',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.status}
|
||||||
|
</InlineCode>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
|
function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
|
||||||
let numVisited = 0;
|
let numVisited = 0;
|
||||||
let numChecked = 0;
|
let numChecked = 0;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function HttpAuthenticationEditor({ request }: Props) {
|
|||||||
onChange={(disabled) => handleChange({ ...request.authentication, disabled: !disabled })}
|
onChange={(disabled) => handleChange({ ...request.authentication, disabled: !disabled })}
|
||||||
title="Enabled"
|
title="Enabled"
|
||||||
/>
|
/>
|
||||||
{authConfig.data.actions && (
|
{authConfig.data.actions && authConfig.data.actions.length > 0 && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={authConfig.data.actions.map(
|
items={authConfig.data.actions.map(
|
||||||
(a): DropdownItem => ({
|
(a): DropdownItem => ({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { jotaiStore } from '../lib/jotai';
|
|||||||
export function initSync() {
|
export function initSync() {
|
||||||
initModelListeners();
|
initModelListeners();
|
||||||
initFileChangeListeners();
|
initFileChangeListeners();
|
||||||
|
sync().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sync({ force }: { force?: boolean } = {}) {
|
export async function sync({ force }: { force?: boolean } = {}) {
|
||||||
@@ -53,6 +54,7 @@ function initFileChangeListeners() {
|
|||||||
await unsub?.(); // Unsub to previous
|
await unsub?.(); // Unsub to previous
|
||||||
const workspaceMeta = jotaiStore.get(workspaceMetaAtom);
|
const workspaceMeta = jotaiStore.get(workspaceMetaAtom);
|
||||||
if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;
|
if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;
|
||||||
|
debouncedSync(); // Perform an initial sync when switching workspace
|
||||||
unsub = watchWorkspaceFiles(
|
unsub = watchWorkspaceFiles(
|
||||||
workspaceMeta.workspaceId,
|
workspaceMeta.workspaceId,
|
||||||
workspaceMeta.settingSyncDir,
|
workspaceMeta.settingSyncDir,
|
||||||
|
|||||||
Reference in New Issue
Block a user