mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
Merge remote-tracking branch 'origin/main' into pr/57500
This commit is contained in:
commit
20fe7ee85d
63 changed files with 3046 additions and 757 deletions
|
|
@ -16,8 +16,9 @@ jobs:
|
||||||
github.event_name == 'issues' &&
|
github.event_name == 'issues' &&
|
||||||
github.repository == 'zed-industries/zed' &&
|
github.repository == 'zed-industries/zed' &&
|
||||||
github.event.issue.pull_request == null &&
|
github.event.issue.pull_request == null &&
|
||||||
github.event.issue.type != null &&
|
(github.event.issue.type == null ||
|
||||||
(github.event.issue.type.name == 'Bug' || github.event.issue.type.name == 'Crash')
|
github.event.issue.type.name == 'Bug' ||
|
||||||
|
github.event.issue.type.name == 'Crash')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -3914,9 +3914,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cosmic-text"
|
name = "cosmic-text"
|
||||||
version = "0.17.1"
|
version = "0.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c5c9868e64aa6c5410629a83450e142c80e721c727a5bc0fb18107af6c2d66b"
|
checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,15 @@ pub const AUTO_OUTLINE_SIZE: usize = 16384;
|
||||||
|
|
||||||
/// Result of getting buffer content, which can be either full content or an outline.
|
/// Result of getting buffer content, which can be either full content or an outline.
|
||||||
pub struct BufferContent {
|
pub struct BufferContent {
|
||||||
/// The actual content (either full text or outline)
|
/// The actual content (either full text, a symbol outline, or a
|
||||||
|
/// truncated fallback — see `is_synthetic`).
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// Whether this is an outline (true) or full content (false)
|
/// `true` when `text` is not the file's full content — either a symbol
|
||||||
pub is_outline: bool,
|
/// outline or the truncated first-1KB fallback used when no outline is
|
||||||
|
/// available. Callers that prefix line numbers to file content must
|
||||||
|
/// skip prefixing in this case, because line numbers in `text` would
|
||||||
|
/// not correspond to the file's real line numbers.
|
||||||
|
pub is_synthetic: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns either the full content of a buffer or its outline, depending on size.
|
/// Returns either the full content of a buffer or its outline, depending on size.
|
||||||
|
|
@ -44,7 +49,10 @@ pub async fn get_buffer_content_or_outline(
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no outline exists, fall back to first 1KB so the agent has some context
|
// If no outline exists, fall back to first 1KB so the agent has some context.
|
||||||
|
// This is reported as `is_synthetic: true` because the returned text is not
|
||||||
|
// the file's full content — it has a synthetic header and is truncated — so
|
||||||
|
// callers must not attach real-file line numbers to it.
|
||||||
if outline_items.is_empty() {
|
if outline_items.is_empty() {
|
||||||
let text = buffer.read_with(cx, |buffer, _| {
|
let text = buffer.read_with(cx, |buffer, _| {
|
||||||
let snapshot = buffer.snapshot();
|
let snapshot = buffer.snapshot();
|
||||||
|
|
@ -59,7 +67,7 @@ pub async fn get_buffer_content_or_outline(
|
||||||
|
|
||||||
return Ok(BufferContent {
|
return Ok(BufferContent {
|
||||||
text,
|
text,
|
||||||
is_outline: false,
|
is_synthetic: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,14 +80,14 @@ pub async fn get_buffer_content_or_outline(
|
||||||
};
|
};
|
||||||
Ok(BufferContent {
|
Ok(BufferContent {
|
||||||
text,
|
text,
|
||||||
is_outline: true,
|
is_synthetic: true,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// File is small enough, return full content
|
// File is small enough, return full content
|
||||||
let text = buffer.read_with(cx, |buffer, _| buffer.text());
|
let text = buffer.read_with(cx, |buffer, _| buffer.text());
|
||||||
Ok(BufferContent {
|
Ok(BufferContent {
|
||||||
text,
|
text,
|
||||||
is_outline: false,
|
is_synthetic: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,10 +204,13 @@ mod tests {
|
||||||
"Result did not contain content subset"
|
"Result did not contain content subset"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should be marked as not an outline (it's truncated content)
|
// Should be marked synthetic: the returned text is not the file's full
|
||||||
|
// content (it's a truncated first-1KB fallback with a synthetic header), so
|
||||||
|
// callers must treat it the same as the symbol-outline case and not attach
|
||||||
|
// real-file line numbers to it.
|
||||||
assert!(
|
assert!(
|
||||||
!result.is_outline,
|
result.is_synthetic,
|
||||||
"Large file without outline should not be marked as outline"
|
"Truncated fallback should be reported as synthetic so callers skip line numbering"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should be reasonably sized (much smaller than original)
|
// Should be reasonably sized (much smaller than original)
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,14 @@ const DEFAULT_UI_TEXT: &str = "Editing file";
|
||||||
|
|
||||||
/// This is a tool for applying edits to an existing file.
|
/// This is a tool for applying edits to an existing file.
|
||||||
///
|
///
|
||||||
/// Before using this tool, use the `read_file` tool to understand the file's contents and context
|
/// Before using this tool, use the `read_file` tool to understand the file's contents and context.
|
||||||
/// To create a new file or overwrite an existing one with completely new contents, use the `write_file` tool instead.
|
/// To create a new file or overwrite an existing one with completely new contents, use the `write_file` tool instead.
|
||||||
|
///
|
||||||
|
/// `read_file` prefixes each line of its output with a line number right-aligned in a
|
||||||
|
/// 6-character field followed by a single tab, then the line's actual content. When you
|
||||||
|
/// derive `old_text` or `new_text` from that output, strip this prefix and keep only what
|
||||||
|
/// comes after the tab, preserving the original indentation (tabs and spaces) exactly.
|
||||||
|
/// Never include any part of the line number prefix in `old_text` or `new_text`.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct EditFileToolInput {
|
pub struct EditFileToolInput {
|
||||||
/// The full path of the file to edit in the project.
|
/// The full path of the file to edit in the project.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,80 @@ fn tool_content_err(e: impl std::fmt::Display) -> LanguageModelToolResultContent
|
||||||
LanguageModelToolResultContent::from(e.to_string())
|
LanguageModelToolResultContent::from(e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves the optional `start_line` / `end_line` inputs from the tool schema
|
||||||
|
/// to a concrete 1-indexed, inclusive `(start, end)` line range:
|
||||||
|
///
|
||||||
|
/// - `start` defaults to 1 and is clamped to `>= 1` (the model occasionally passes
|
||||||
|
/// `0` despite instructions to be 1-indexed).
|
||||||
|
/// - `end` defaults to `u32::MAX` and is clamped to `>= start`, so callers always
|
||||||
|
/// read at least one line even when the model passes `end < start`.
|
||||||
|
///
|
||||||
|
/// Callers translate this 1-indexed inclusive range to whichever coordinate
|
||||||
|
/// system their slicing API wants (e.g. 0-indexed exclusive row ranges for
|
||||||
|
/// `Buffer::text_for_range`).
|
||||||
|
fn resolve_line_range(start_line: Option<u32>, end_line: Option<u32>) -> (u32, u32) {
|
||||||
|
let start = start_line.unwrap_or(1).max(1);
|
||||||
|
let end = end_line.unwrap_or(u32::MAX).max(start);
|
||||||
|
(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefixes each line of `text` with its line number in `cat -n` format:
|
||||||
|
/// the line number is right-aligned in a 6-character field, followed by a
|
||||||
|
/// single tab, followed by the line's original content (including its
|
||||||
|
/// trailing newline if present). Numbering starts at `start_line`.
|
||||||
|
///
|
||||||
|
/// This format matches what the model expects in the edit tool, where the
|
||||||
|
/// line number prefix is `line number + tab` and everything after the tab is
|
||||||
|
/// the actual file content to match.
|
||||||
|
fn format_with_line_numbers(text: &str, start_line: u32) -> String {
|
||||||
|
if text.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::with_capacity(text.len() + text.len() / 4);
|
||||||
|
write_lines_numbered(&mut output, std::iter::once(text), start_line);
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams `cat -n`-style line-numbered output directly into `output` from an
|
||||||
|
/// iterator of string slices. Chunks do not need to align to line boundaries:
|
||||||
|
/// a single chunk may contain multiple newlines, span multiple lines, or end
|
||||||
|
/// mid-line. This lets callers consume `Buffer::text_for_range`'s `Chunks`
|
||||||
|
/// iterator without materializing the unnumbered text first.
|
||||||
|
fn write_lines_numbered<'a>(
|
||||||
|
output: &mut String,
|
||||||
|
chunks: impl IntoIterator<Item = &'a str>,
|
||||||
|
start_line: u32,
|
||||||
|
) {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
let mut line_number = start_line;
|
||||||
|
let mut at_line_start = true;
|
||||||
|
for chunk in chunks {
|
||||||
|
let mut rest = chunk;
|
||||||
|
while !rest.is_empty() {
|
||||||
|
if at_line_start {
|
||||||
|
// Writes to a `String` are infallible, so the `Result` can be ignored.
|
||||||
|
let _ = write!(output, "{line_number:>6}\t");
|
||||||
|
at_line_start = false;
|
||||||
|
}
|
||||||
|
match rest.find('\n') {
|
||||||
|
Some(nl) => {
|
||||||
|
let (head, tail) = rest.split_at(nl + 1);
|
||||||
|
output.push_str(head);
|
||||||
|
line_number = line_number.saturating_add(1);
|
||||||
|
at_line_start = true;
|
||||||
|
rest = tail;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
output.push_str(rest);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a file under the global skills directory directly via the filesystem,
|
/// Read a file under the global skills directory directly via the filesystem,
|
||||||
/// bypassing project/worktree resolution. Used for skill resources that live
|
/// bypassing project/worktree resolution. Used for skill resources that live
|
||||||
/// outside any worktree.
|
/// outside any worktree.
|
||||||
|
|
@ -40,26 +114,21 @@ async fn read_global_skill_file(
|
||||||
.line(start_line.map(|line| line.saturating_sub(1))),
|
.line(start_line.map(|line| line.saturating_sub(1))),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
let result_text = if start_line.is_some() || end_line.is_some() {
|
let (raw_text, first_line_number) = if start_line.is_some() || end_line.is_some() {
|
||||||
// Mirror the line-range semantics of the buffer-backed path: 1-indexed,
|
// `split_inclusive` keeps each line's terminator attached, so CRLF stays
|
||||||
// start clamped to >= 1, end exclusive of the next line, and always
|
// CRLF and the trailing newline of the last returned line is preserved —
|
||||||
// returning at least one line. `split_inclusive` keeps each line's
|
// matching `Buffer::text_for_range` in the buffer-backed path.
|
||||||
// terminator attached, so CRLF stays CRLF and the trailing newline of
|
let (start, end) = resolve_line_range(start_line, end_line);
|
||||||
// the last returned line is preserved — matching `Buffer::text_for_range`.
|
|
||||||
let start = start_line.unwrap_or(1).max(1);
|
|
||||||
let mut end = end_line.unwrap_or(u32::MAX);
|
|
||||||
if end < start {
|
|
||||||
end = start;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines: Vec<&str> = content.split_inclusive('\n').collect();
|
let lines: Vec<&str> = content.split_inclusive('\n').collect();
|
||||||
let start_idx = (start as usize).saturating_sub(1).min(lines.len());
|
let start_idx = (start as usize).saturating_sub(1).min(lines.len());
|
||||||
let end_idx = (end as usize).min(lines.len()).max(start_idx);
|
let end_idx = (end as usize).min(lines.len()).max(start_idx);
|
||||||
lines[start_idx..end_idx].concat()
|
(lines[start_idx..end_idx].concat(), start)
|
||||||
} else {
|
} else {
|
||||||
content
|
(content, 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let result_text = format_with_line_numbers(&raw_text, first_line_number);
|
||||||
|
|
||||||
let markdown = MarkdownCodeBlock {
|
let markdown = MarkdownCodeBlock {
|
||||||
tag: requested_path,
|
tag: requested_path,
|
||||||
text: &result_text,
|
text: &result_text,
|
||||||
|
|
@ -342,29 +411,38 @@ impl AgentTool for ReadFileTool {
|
||||||
|
|
||||||
// Check if specific line ranges are provided
|
// Check if specific line ranges are provided
|
||||||
let result = if input.start_line.is_some() || input.end_line.is_some() {
|
let result = if input.start_line.is_some() || input.end_line.is_some() {
|
||||||
let result = buffer.read_with(cx, |buffer, _cx| {
|
let result_text = buffer.read_with(cx, |buffer, _cx| {
|
||||||
// .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
|
let (start, end) = resolve_line_range(input.start_line, input.end_line);
|
||||||
let start = input.start_line.unwrap_or(1).max(1);
|
|
||||||
let start_row = start - 1;
|
let start_row = start - 1;
|
||||||
if start_row <= buffer.max_point().row {
|
if start_row <= buffer.max_point().row {
|
||||||
let column = buffer.line_indent_for_row(start_row).raw_len();
|
let column = buffer.line_indent_for_row(start_row).raw_len();
|
||||||
anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
|
anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut end_row = input.end_line.unwrap_or(u32::MAX);
|
// `end` is 1-indexed inclusive; `Point` rows are 0-indexed.
|
||||||
if end_row <= start_row {
|
// Using `end` directly as the (exclusive) end row is the
|
||||||
end_row = start_row + 1; // read at least one lines
|
// standard inclusive→exclusive translation, and since
|
||||||
}
|
// `resolve_line_range` guarantees `end >= start`, we always
|
||||||
let start = buffer.anchor_before(Point::new(start_row, 0));
|
// read at least one line.
|
||||||
let end = buffer.anchor_before(Point::new(end_row, 0));
|
let start_anchor = buffer.anchor_before(Point::new(start_row, 0));
|
||||||
buffer.text_for_range(start..end).collect::<String>()
|
let end_anchor = buffer.anchor_before(Point::new(end, 0));
|
||||||
|
// Stream the numbered output directly from the buffer's
|
||||||
|
// chunk iterator so the unnumbered range is never
|
||||||
|
// materialized as its own `String`.
|
||||||
|
let mut output = String::new();
|
||||||
|
write_lines_numbered(
|
||||||
|
&mut output,
|
||||||
|
buffer.text_for_range(start_anchor..end_anchor),
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
output
|
||||||
});
|
});
|
||||||
|
|
||||||
action_log.update(cx, |log, cx| {
|
action_log.update(cx, |log, cx| {
|
||||||
log.buffer_read(buffer.clone(), cx);
|
log.buffer_read(buffer.clone(), cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(result.into())
|
Ok(result_text.into())
|
||||||
} else {
|
} else {
|
||||||
// No line ranges specified, so check file size to see if it's too big.
|
// No line ranges specified, so check file size to see if it's too big.
|
||||||
let buffer_content = outline::get_buffer_content_or_outline(
|
let buffer_content = outline::get_buffer_content_or_outline(
|
||||||
|
|
@ -378,9 +456,10 @@ impl AgentTool for ReadFileTool {
|
||||||
log.buffer_read(buffer.clone(), cx);
|
log.buffer_read(buffer.clone(), cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
is_outline_response = buffer_content.is_outline;
|
|
||||||
|
|
||||||
if buffer_content.is_outline {
|
is_outline_response = buffer_content.is_synthetic;
|
||||||
|
|
||||||
|
if buffer_content.is_synthetic {
|
||||||
Ok(formatdoc! {"
|
Ok(formatdoc! {"
|
||||||
SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers.
|
SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers.
|
||||||
|
|
||||||
|
|
@ -394,7 +473,7 @@ impl AgentTool for ReadFileTool {
|
||||||
}
|
}
|
||||||
.into())
|
.into())
|
||||||
} else {
|
} else {
|
||||||
Ok(buffer_content.text.into())
|
Ok(format_with_line_numbers(&buffer_content.text, 1).into())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -427,6 +506,27 @@ impl AgentTool for ReadFileTool {
|
||||||
result
|
result
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replay(
|
||||||
|
&self,
|
||||||
|
input: Self::Input,
|
||||||
|
output: Self::Output,
|
||||||
|
event_stream: ToolCallEventStream,
|
||||||
|
_cx: &mut App,
|
||||||
|
) -> Result<()> {
|
||||||
|
if let LanguageModelToolResultContent::Text(text) = output {
|
||||||
|
let markdown = MarkdownCodeBlock {
|
||||||
|
tag: &input.path,
|
||||||
|
text: &text,
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
|
||||||
|
acp::ToolCallContent::Content(acp::Content::new(markdown)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -530,7 +630,10 @@ mod test {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(result.unwrap(), "This is a small file content".into());
|
assert_eq!(
|
||||||
|
result.unwrap(),
|
||||||
|
" 1\tThis is a small file content".into()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -777,7 +880,7 @@ mod test {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(result.unwrap(), "root content".into());
|
assert_eq!(result.unwrap(), " 1\troot content".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -810,7 +913,10 @@ mod test {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
|
assert_eq!(
|
||||||
|
result.unwrap(),
|
||||||
|
" 2\tLine 2\n 3\tLine 3\n 4\tLine 4\n".into()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -844,7 +950,7 @@ mod test {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
|
assert_eq!(result.unwrap(), " 1\tLine 1\n 2\tLine 2\n".into());
|
||||||
|
|
||||||
// end_line of 0 should result in at least 1 line
|
// end_line of 0 should result in at least 1 line
|
||||||
let result = cx
|
let result = cx
|
||||||
|
|
@ -861,7 +967,7 @@ mod test {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(result.unwrap(), "Line 1\n".into());
|
assert_eq!(result.unwrap(), " 1\tLine 1\n".into());
|
||||||
|
|
||||||
// when start_line > end_line, should still return at least 1 line
|
// when start_line > end_line, should still return at least 1 line
|
||||||
let result = cx
|
let result = cx
|
||||||
|
|
@ -878,7 +984,7 @@ mod test {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(result.unwrap(), "Line 3\n".into());
|
assert_eq!(result.unwrap(), " 3\tLine 3\n".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error_text(content: LanguageModelToolResultContent) -> String {
|
fn error_text(content: LanguageModelToolResultContent) -> String {
|
||||||
|
|
@ -1112,7 +1218,7 @@ mod test {
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert!(result.is_ok(), "Should be able to read normal files");
|
assert!(result.is_ok(), "Should be able to read normal files");
|
||||||
assert_eq!(result.unwrap(), "Normal file content".into());
|
assert_eq!(result.unwrap(), " 1\tNormal file content".into());
|
||||||
|
|
||||||
// Path traversal attempts with .. should fail
|
// Path traversal attempts with .. should fail
|
||||||
let result = cx
|
let result = cx
|
||||||
|
|
@ -1282,7 +1388,7 @@ mod test {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
"fn main() { println!(\"Hello from worktree1\"); }".into()
|
" 1\tfn main() { println!(\"Hello from worktree1\"); }".into()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test reading private file in worktree1 should fail
|
// Test reading private file in worktree1 should fail
|
||||||
|
|
@ -1348,7 +1454,7 @@ mod test {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
"export function greet() { return 'Hello from worktree2'; }".into()
|
" 1\texport function greet() { return 'Hello from worktree2'; }".into()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test reading private file in worktree2 should fail
|
// Test reading private file in worktree2 should fail
|
||||||
|
|
@ -1658,7 +1764,10 @@ mod test {
|
||||||
let LanguageModelToolResultContent::Text(text) = content else {
|
let LanguageModelToolResultContent::Text(text) = content else {
|
||||||
panic!("expected text content");
|
panic!("expected text content");
|
||||||
};
|
};
|
||||||
assert_eq!(text.as_ref(), "# Spec\n\nReference body.");
|
assert_eq!(
|
||||||
|
text.as_ref(),
|
||||||
|
" 1\t# Spec\n 2\t\n 3\tReference body."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -1705,7 +1814,7 @@ mod test {
|
||||||
};
|
};
|
||||||
// Mirrors the buffer-backed path: lines 2-3 inclusive, WITH trailing
|
// Mirrors the buffer-backed path: lines 2-3 inclusive, WITH trailing
|
||||||
// newline of the last returned line.
|
// newline of the last returned line.
|
||||||
assert_eq!(text.as_ref(), "line two\nline three\n");
|
assert_eq!(text.as_ref(), " 2\tline two\n 3\tline three\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -1750,7 +1859,7 @@ mod test {
|
||||||
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
||||||
panic!("expected text content");
|
panic!("expected text content");
|
||||||
};
|
};
|
||||||
assert_eq!(text.as_ref(), "Line 1\nLine 2\n");
|
assert_eq!(text.as_ref(), " 1\tLine 1\n 2\tLine 2\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -1795,7 +1904,7 @@ mod test {
|
||||||
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
||||||
panic!("expected text content");
|
panic!("expected text content");
|
||||||
};
|
};
|
||||||
assert_eq!(text.as_ref(), "Line 1\n");
|
assert_eq!(text.as_ref(), " 1\tLine 1\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -1840,7 +1949,7 @@ mod test {
|
||||||
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
||||||
panic!("expected text content");
|
panic!("expected text content");
|
||||||
};
|
};
|
||||||
assert_eq!(text.as_ref(), "Line 3\n");
|
assert_eq!(text.as_ref(), " 3\tLine 3\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
@ -1885,7 +1994,7 @@ mod test {
|
||||||
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
let LanguageModelToolResultContent::Text(text) = result.unwrap() else {
|
||||||
panic!("expected text content");
|
panic!("expected text content");
|
||||||
};
|
};
|
||||||
assert_eq!(text.as_ref(), "line one\r\nline two\r\n");
|
assert_eq!(text.as_ref(), " 1\tline one\r\n 2\tline two\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ pub struct SkillScopeId(pub usize);
|
||||||
/// entries would fan out an equally large number of concurrent OS-level I/O
|
/// entries would fan out an equally large number of concurrent OS-level I/O
|
||||||
/// operations, potentially exhausting file descriptors or stalling the app.
|
/// operations, potentially exhausting file descriptors or stalling the app.
|
||||||
const SKILL_IO_CONCURRENCY: usize = 16;
|
const SKILL_IO_CONCURRENCY: usize = 16;
|
||||||
|
const SKILL_READ_CHUNK_SIZE: usize = 4096;
|
||||||
|
|
||||||
/// Maximum size for a single SKILL.md file (100KB)
|
/// Maximum size for a single SKILL.md file (100KB)
|
||||||
pub const MAX_SKILL_FILE_SIZE: usize = 100 * 1024;
|
pub const MAX_SKILL_FILE_SIZE: usize = 100 * 1024;
|
||||||
|
|
@ -631,14 +632,17 @@ pub async fn load_skill_frontmatter(
|
||||||
// panic with "Parking forbidden" under `TestAppContext`.
|
// panic with "Parking forbidden" under `TestAppContext`.
|
||||||
let read_result: Result<Vec<u8>, io::Error> = (|| {
|
let read_result: Result<Vec<u8>, io::Error> = (|| {
|
||||||
let mut accumulated: Vec<u8> = Vec::new();
|
let mut accumulated: Vec<u8> = Vec::new();
|
||||||
let mut chunk = [0u8; 4096];
|
let mut chunk = [0u8; SKILL_READ_CHUNK_SIZE];
|
||||||
loop {
|
loop {
|
||||||
let n = reader.read(&mut chunk)?;
|
let n = reader.read(&mut chunk)?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
accumulated.extend_from_slice(&chunk[..n]);
|
accumulated.extend_from_slice(&chunk[..n]);
|
||||||
if closing_delimiter_end(&accumulated).is_some() {
|
if let Some(end) = closing_delimiter_end(&accumulated) {
|
||||||
|
// Discard body bytes swept up in the last chunk so that e.g. multi-byte
|
||||||
|
// graphemes split at the boundary won't cause `str::from_utf8` to fail.
|
||||||
|
accumulated.truncate(end);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if accumulated.len() > MAX_SKILL_FILE_SIZE {
|
if accumulated.len() > MAX_SKILL_FILE_SIZE {
|
||||||
|
|
@ -1799,6 +1803,46 @@ description: A skill with no body content
|
||||||
assert_eq!(skill.directory_path, PathBuf::from("/skills/my-skill"));
|
assert_eq!(skill.directory_path, PathBuf::from("/skills/my-skill"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_load_skill_frontmatter_with_emoji_at_chunk_boundary(cx: &mut TestAppContext) {
|
||||||
|
// We must be able to load skill frontmatter even when a
|
||||||
|
// multipoint grapheme crosses the chunk read boundary.
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
let frontmatter = "---\nname: my-skill\ndescription: Example skill testing multipoint graphemes at chunk boundary\n---\n";
|
||||||
|
|
||||||
|
// Pad contents so that the emoji's first byte lands
|
||||||
|
// at the last byte of the first read chunk.
|
||||||
|
let padding = "a".repeat(SKILL_READ_CHUNK_SIZE - frontmatter.len() - 1);
|
||||||
|
let content = format!("{frontmatter}{padding}✅");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(frontmatter.len() + padding.len()) < SKILL_READ_CHUNK_SIZE,
|
||||||
|
"emoji must start before the second chunk"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.len() > SKILL_READ_CHUNK_SIZE,
|
||||||
|
"skill is longer than a chunk, so we know that the emoji crosses chunk boundaries"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.insert_tree(
|
||||||
|
"/skills",
|
||||||
|
serde_json::json!({
|
||||||
|
"my-skill": {
|
||||||
|
"SKILL.md": content,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
load_skill_frontmatter(
|
||||||
|
fs as Arc<dyn Fs>,
|
||||||
|
PathBuf::from("/skills/my-skill/SKILL.md"),
|
||||||
|
SkillSource::Global,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("frontmatter should parse even when a multipoint grapheme such as an emoji crosses the byte chunk boundary");
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_read_skill_body_returns_trimmed_body(cx: &mut TestAppContext) {
|
async fn test_read_skill_body_returns_trimmed_body(cx: &mut TestAppContext) {
|
||||||
let fs = FakeFs::new(cx.executor());
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,10 @@ impl Item for AgentDiffPane {
|
||||||
.for_each_project_item(cx, f)
|
.for_each_project_item(cx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(
|
fn set_nav_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
nav_history: ItemNavHistory,
|
nav_history: ItemNavHistory,
|
||||||
|
|
@ -2253,7 +2257,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
let editor2_path = editor2
|
let editor2_path = editor2
|
||||||
.read_with(cx, |editor, cx| editor.project_path(cx))
|
.read_with(cx, |editor, cx| editor.active_project_path(cx))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(editor2_path, buffer_path2);
|
assert_eq!(editor2_path, buffer_path2);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,11 @@ use futures::FutureExt as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
|
Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle,
|
||||||
ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
|
ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState,
|
||||||
ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TaskExt, TextStyle,
|
ObjectFit, PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task,
|
||||||
WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient,
|
TaskExt, TextRun, TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img,
|
||||||
list, point, pulsating_between,
|
linear_color_stop, linear_gradient, list, point, pulsating_between,
|
||||||
};
|
};
|
||||||
use language::Buffer;
|
use language::{Buffer, Language, Rope};
|
||||||
use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
|
use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
|
||||||
use markdown::{
|
use markdown::{
|
||||||
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
|
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
|
||||||
|
|
@ -357,6 +357,33 @@ impl Conversation {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the first pending tool call request for exactly `session_id`.
|
||||||
|
/// Unlike `pending_tool_call`, this does not use the global FIFO pending
|
||||||
|
/// request for non-subagent sessions.
|
||||||
|
pub fn pending_tool_call_for_session(
|
||||||
|
&self,
|
||||||
|
session_id: &acp::SessionId,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<acp::ToolCallId> {
|
||||||
|
let thread = self.threads.get(session_id)?;
|
||||||
|
let tool_call_id = self.permission_requests.get(session_id)?.iter().next()?;
|
||||||
|
let (_, tool_call) = thread.read(cx).tool_call(tool_call_id)?;
|
||||||
|
if !matches!(
|
||||||
|
tool_call.status,
|
||||||
|
ToolCallStatus::WaitingForConfirmation { .. }
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(tool_call_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_tool_call_count_for_session(&self, session_id: &acp::SessionId) -> usize {
|
||||||
|
self.permission_requests
|
||||||
|
.get(session_id)
|
||||||
|
.map(|tool_call_ids| tool_call_ids.len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn authorize_pending_tool_call(
|
pub fn authorize_pending_tool_call(
|
||||||
&mut self,
|
&mut self,
|
||||||
session_id: &acp::SessionId,
|
session_id: &acp::SessionId,
|
||||||
|
|
@ -3366,7 +3393,7 @@ pub(crate) mod tests {
|
||||||
use editor::MultiBufferOffset;
|
use editor::MultiBufferOffset;
|
||||||
use editor::actions::Paste;
|
use editor::actions::Paste;
|
||||||
use fs::FakeFs;
|
use fs::FakeFs;
|
||||||
use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext};
|
use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, size};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use project::Project;
|
use project::Project;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
@ -7981,6 +8008,343 @@ pub(crate) mod tests {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set up a `ConversationView` whose active thread has a single tool call
|
||||||
|
/// awaiting permission. Returns the conversation view, its active
|
||||||
|
/// `ThreadView`, and the entry index of the tool call within the thread.
|
||||||
|
async fn setup_pending_permission_thread<'a>(
|
||||||
|
tool_call_id: &str,
|
||||||
|
cx: &'a mut TestAppContext,
|
||||||
|
) -> (
|
||||||
|
Entity<ConversationView>,
|
||||||
|
Entity<ThreadView>,
|
||||||
|
usize,
|
||||||
|
&'a mut VisualTestContext,
|
||||||
|
) {
|
||||||
|
let tool_call_id_value = acp::ToolCallId::new(tool_call_id);
|
||||||
|
let tool_call = acp::ToolCall::new(tool_call_id_value.clone(), "Run something")
|
||||||
|
.kind(acp::ToolKind::Edit);
|
||||||
|
|
||||||
|
let connection =
|
||||||
|
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||||
|
tool_call_id_value.clone(),
|
||||||
|
PermissionOptions::Flat(vec![acp::PermissionOption::new(
|
||||||
|
"allow",
|
||||||
|
"Allow",
|
||||||
|
acp::PermissionOptionKind::AllowOnce,
|
||||||
|
)]),
|
||||||
|
)]));
|
||||||
|
connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]);
|
||||||
|
|
||||||
|
let (conversation_view, cx) =
|
||||||
|
setup_conversation_view(StubAgentServer::new(connection), cx).await;
|
||||||
|
add_to_workspace(conversation_view.clone(), cx);
|
||||||
|
|
||||||
|
cx.update(|_window, cx| {
|
||||||
|
AgentSettings::override_global(
|
||||||
|
AgentSettings {
|
||||||
|
notify_when_agent_waiting: NotifyWhenAgentWaiting::Never,
|
||||||
|
..AgentSettings::get_global(cx).clone()
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let message_editor = message_editor(&conversation_view, cx);
|
||||||
|
message_editor.update_in(cx, |editor, window, cx| {
|
||||||
|
editor.set_text("Hello", window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
active_thread(&conversation_view, cx)
|
||||||
|
.update_in(cx, |view, window, cx| view.send(window, cx));
|
||||||
|
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let thread_view = active_thread(&conversation_view, cx);
|
||||||
|
let entry_ix = thread_view.read_with(cx, |view, cx| {
|
||||||
|
view.thread
|
||||||
|
.read(cx)
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.position(|entry| {
|
||||||
|
matches!(
|
||||||
|
entry,
|
||||||
|
acp_thread::AgentThreadEntry::ToolCall(call)
|
||||||
|
if call.id == tool_call_id_value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("tool call entry should exist after run_until_parked")
|
||||||
|
});
|
||||||
|
|
||||||
|
(conversation_view, thread_view, entry_ix, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestListView {
|
||||||
|
list_state: ListState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TestListView {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
list(self.list_state.clone(), |_, _, _| {
|
||||||
|
div().h(px(20.0)).w_full().into_any_element()
|
||||||
|
})
|
||||||
|
.size_full()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_thread_list_at(
|
||||||
|
thread_view: &Entity<ThreadView>,
|
||||||
|
scroll_top: ListOffset,
|
||||||
|
cx: &mut VisualTestContext,
|
||||||
|
) {
|
||||||
|
let list_state = thread_view.read_with(cx, |view, _cx| view.list_state.clone());
|
||||||
|
list_state.scroll_to(scroll_top);
|
||||||
|
cx.draw(
|
||||||
|
point(px(0.0), px(0.0)),
|
||||||
|
size(px(100.0), px(20.0)),
|
||||||
|
|_, cx| {
|
||||||
|
cx.new(|_| TestListView {
|
||||||
|
list_state: list_state.clone(),
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_permission_row_hidden_when_inline_bounds_unavailable(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let (_view, thread_view, _entry_ix, cx) =
|
||||||
|
setup_pending_permission_thread("perm-no-bounds", cx).await;
|
||||||
|
|
||||||
|
thread_view.update_in(cx, |view, window, cx| {
|
||||||
|
assert!(
|
||||||
|
view.render_main_agent_awaiting_permission(window, cx)
|
||||||
|
.is_none(),
|
||||||
|
"Floating row should stay hidden until the inline prompt has known list bounds"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_pending_tool_call_for_session_scopes_to_that_session(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
let project = Project::test(fs, [], cx).await;
|
||||||
|
let connection: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
|
||||||
|
|
||||||
|
let session_id_a = acp::SessionId::new("thread-a");
|
||||||
|
let session_id_b = acp::SessionId::new("thread-b");
|
||||||
|
let (thread_a, thread_b, conversation) = cx.update(|cx| {
|
||||||
|
let thread_a =
|
||||||
|
create_test_acp_thread(None, "thread-a", connection.clone(), project.clone(), cx);
|
||||||
|
let thread_b =
|
||||||
|
create_test_acp_thread(None, "thread-b", connection.clone(), project.clone(), cx);
|
||||||
|
let conversation = cx.new(|cx| {
|
||||||
|
let mut conversation = Conversation::default();
|
||||||
|
conversation.register_thread(thread_a.clone(), cx);
|
||||||
|
conversation.register_thread(thread_b.clone(), cx);
|
||||||
|
conversation
|
||||||
|
});
|
||||||
|
(thread_a, thread_b, conversation)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pending tool calls in both threads. Unlike `pending_tool_call`,
|
||||||
|
// `pending_tool_call_for_session` must not fall back across threads.
|
||||||
|
let _task_a = request_test_tool_authorization(&thread_a, "tc-a", "allow-a", cx);
|
||||||
|
let _task_b = request_test_tool_authorization(&thread_b, "tc-b", "allow-b", cx);
|
||||||
|
|
||||||
|
cx.read(|cx| {
|
||||||
|
let tool_call_id_a = conversation
|
||||||
|
.read(cx)
|
||||||
|
.pending_tool_call_for_session(&session_id_a, cx)
|
||||||
|
.expect("Expected a pending tool call in thread A");
|
||||||
|
assert_eq!(tool_call_id_a, acp::ToolCallId::new("tc-a"));
|
||||||
|
|
||||||
|
let tool_call_id_b = conversation
|
||||||
|
.read(cx)
|
||||||
|
.pending_tool_call_for_session(&session_id_b, cx)
|
||||||
|
.expect("Expected a pending tool call in thread B");
|
||||||
|
assert_eq!(tool_call_id_b, acp::ToolCallId::new("tc-b"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_permission_row_scroll_to_dismisses_row(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let (_view, thread_view, entry_ix, cx) =
|
||||||
|
setup_pending_permission_thread("perm-scroll", cx).await;
|
||||||
|
|
||||||
|
// Start off-screen below the viewport — row visible because the item
|
||||||
|
// has bounds that do not intersect the viewport.
|
||||||
|
draw_thread_list_at(
|
||||||
|
&thread_view,
|
||||||
|
ListOffset {
|
||||||
|
item_ix: 0,
|
||||||
|
offset_in_item: px(0.0),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
thread_view.read_with(cx, |view, _cx| {
|
||||||
|
assert!(
|
||||||
|
view.list_state.bounds_for_item(entry_ix).is_some(),
|
||||||
|
"The tool call entry must be measured for this test to exercise the\
|
||||||
|
\"entry below viewport\" branch. If list overdraw stops measuring\
|
||||||
|
offscreen items, this test needs to drive measurement another way."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
thread_view.update_in(cx, |view, window, cx| {
|
||||||
|
assert!(
|
||||||
|
view.render_main_agent_awaiting_permission(window, cx)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate clicking "Scroll to": the list scrolls to the entry and the
|
||||||
|
// measured item bounds intersect the viewport.
|
||||||
|
draw_thread_list_at(
|
||||||
|
&thread_view,
|
||||||
|
ListOffset {
|
||||||
|
item_ix: entry_ix,
|
||||||
|
offset_in_item: px(0.0),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
thread_view.update_in(cx, |view, window, cx| {
|
||||||
|
assert!(
|
||||||
|
view.render_main_agent_awaiting_permission(window, cx)
|
||||||
|
.is_none(),
|
||||||
|
"Floating row should disappear after scrolling brings the inline prompt into view"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_permission_row_disappears_when_authorized(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
let (conversation_view, thread_view, _entry_ix, cx) =
|
||||||
|
setup_pending_permission_thread("perm-allow", cx).await;
|
||||||
|
|
||||||
|
// Park the inline prompt below the viewport so the floating row would render.
|
||||||
|
draw_thread_list_at(
|
||||||
|
&thread_view,
|
||||||
|
ListOffset {
|
||||||
|
item_ix: 0,
|
||||||
|
offset_in_item: px(0.0),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
thread_view.update_in(cx, |view, window, cx| {
|
||||||
|
assert!(
|
||||||
|
view.render_main_agent_awaiting_permission(window, cx)
|
||||||
|
.is_some(),
|
||||||
|
"Floating row should be visible before authorizing"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch the same AuthorizeToolCall action the row's Allow button
|
||||||
|
// wires up.
|
||||||
|
conversation_view.update_in(cx, |_, window, cx| {
|
||||||
|
window.dispatch_action(
|
||||||
|
crate::AuthorizeToolCall {
|
||||||
|
tool_call_id: "perm-allow".to_string(),
|
||||||
|
option_id: "allow".to_string(),
|
||||||
|
option_kind: "AllowOnce".to_string(),
|
||||||
|
}
|
||||||
|
.boxed_clone(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
conversation_view.read_with(cx, |view, cx| {
|
||||||
|
assert!(
|
||||||
|
view.pending_tool_call(cx).is_none(),
|
||||||
|
"Tool call should no longer be pending after Allow is clicked"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
thread_view.update_in(cx, |view, window, cx| {
|
||||||
|
assert!(
|
||||||
|
view.render_main_agent_awaiting_permission(window, cx)
|
||||||
|
.is_none(),
|
||||||
|
"Floating row should disappear once the permission is granted"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_permission_row_ignores_subagent_requests(cx: &mut TestAppContext) {
|
||||||
|
init_test(cx);
|
||||||
|
|
||||||
|
// Build a baseline ConversationView with no permission requests, so we
|
||||||
|
// have a real `ThreadView` to call `render_main_agent_awaiting_permission` on.
|
||||||
|
let (conversation_view, cx) =
|
||||||
|
setup_conversation_view(StubAgentServer::default_response(), cx).await;
|
||||||
|
add_to_workspace(conversation_view.clone(), cx);
|
||||||
|
|
||||||
|
let message_editor = message_editor(&conversation_view, cx);
|
||||||
|
message_editor.update_in(cx, |editor, window, cx| {
|
||||||
|
editor.set_text("Hello", window, cx);
|
||||||
|
});
|
||||||
|
active_thread(&conversation_view, cx)
|
||||||
|
.update_in(cx, |view, window, cx| view.send(window, cx));
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let thread_view = active_thread(&conversation_view, cx);
|
||||||
|
let parent_session_id =
|
||||||
|
thread_view.read_with(cx, |view, cx| view.thread.read(cx).session_id().clone());
|
||||||
|
let conversation = thread_view.read_with(cx, |view, _cx| view.conversation.clone());
|
||||||
|
|
||||||
|
// Attach a subagent thread with a pending tool-call permission request.
|
||||||
|
let fs = FakeFs::new(cx.executor());
|
||||||
|
let project = Project::test(fs, [], cx).await;
|
||||||
|
let stub: Rc<dyn AgentConnection> = Rc::new(StubAgentConnection::new());
|
||||||
|
let subagent_thread = cx.update(|_window, cx| {
|
||||||
|
create_test_acp_thread(
|
||||||
|
Some(parent_session_id.clone()),
|
||||||
|
"subagent",
|
||||||
|
stub,
|
||||||
|
project,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
conversation.update(cx, |conversation, cx| {
|
||||||
|
conversation.register_thread(subagent_thread.clone(), cx);
|
||||||
|
});
|
||||||
|
let _subagent_task =
|
||||||
|
request_test_tool_authorization(&subagent_thread, "sub-tc", "allow-sub", cx);
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
cx.read(|cx| {
|
||||||
|
assert!(
|
||||||
|
conversation
|
||||||
|
.read(cx)
|
||||||
|
.pending_tool_call_for_session(&parent_session_id, cx)
|
||||||
|
.is_none(),
|
||||||
|
"Subagent requests must not surface as pending in the parent session"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!conversation
|
||||||
|
.read(cx)
|
||||||
|
.subagents_awaiting_permission(cx)
|
||||||
|
.is_empty(),
|
||||||
|
"Subagent permission row should still see the pending request"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
thread_view.update_in(cx, |view, window, cx| {
|
||||||
|
assert!(
|
||||||
|
view.render_main_agent_awaiting_permission(window, cx)
|
||||||
|
.is_none(),
|
||||||
|
"Subagent permission requests should not trigger the main-agent floating row"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
|
async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
|
||||||
init_test(cx);
|
init_test(cx);
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,273 @@ pub enum AcpThreadViewEvent {
|
||||||
|
|
||||||
impl EventEmitter<AcpThreadViewEvent> for ThreadView {}
|
impl EventEmitter<AcpThreadViewEvent> for ThreadView {}
|
||||||
|
|
||||||
|
/// `cat -n`-style numbered code block, already stripped of its line-number
|
||||||
|
/// prefixes and ready to render. Line numbers are guaranteed to be contiguous
|
||||||
|
/// starting at `first_number`, so we only store the first number and the line
|
||||||
|
/// count rather than allocating a per-line `Vec`.
|
||||||
|
struct ParsedCatNumberedCode {
|
||||||
|
code: String,
|
||||||
|
first_number: u32,
|
||||||
|
line_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cat_numbered_markdown_code_block(markdown: &str) -> Option<ParsedCatNumberedCode> {
|
||||||
|
let (_tag, code) = parse_single_fenced_code_block(markdown)?;
|
||||||
|
parse_cat_numbered_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_single_fenced_code_block(markdown: &str) -> Option<(&str, &str)> {
|
||||||
|
let first_non_backtick = markdown.find(|character| character != '`')?;
|
||||||
|
if first_non_backtick < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fence = &markdown[..first_non_backtick];
|
||||||
|
let after_opening_fence = &markdown[first_non_backtick..];
|
||||||
|
let tag_end = after_opening_fence.find('\n')?;
|
||||||
|
let tag = &after_opening_fence[..tag_end];
|
||||||
|
let after_tag = &after_opening_fence[tag_end + 1..];
|
||||||
|
let closing_fence = format!("\n{fence}\n");
|
||||||
|
let code = after_tag.strip_suffix(&closing_fence)?;
|
||||||
|
Some((tag, code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks `code` exactly once: for each line it validates and strips the
|
||||||
|
/// `NNN\t` prefix, then pushes the line's content into the accumulating
|
||||||
|
/// code buffer (with `\n` between lines, no trailing newline). Verifies that
|
||||||
|
/// the line numbers form a contiguous, increasing sequence.
|
||||||
|
fn parse_cat_numbered_code(code: &str) -> Option<ParsedCatNumberedCode> {
|
||||||
|
if code.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::with_capacity(code.len());
|
||||||
|
let mut first_number = None;
|
||||||
|
let mut expected_number = None;
|
||||||
|
let mut line_count: usize = 0;
|
||||||
|
for raw_line in code.split_inclusive('\n') {
|
||||||
|
let line = strip_line_ending(raw_line);
|
||||||
|
let (number, text) = parse_cat_numbered_line(line)?;
|
||||||
|
if let Some(expected) = expected_number {
|
||||||
|
if number != expected {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
first_number = Some(number);
|
||||||
|
}
|
||||||
|
expected_number = number.checked_add(1);
|
||||||
|
if line_count > 0 {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
output.push_str(text);
|
||||||
|
line_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ParsedCatNumberedCode {
|
||||||
|
code: output,
|
||||||
|
first_number: first_number?,
|
||||||
|
line_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_line_ending(line: &str) -> &str {
|
||||||
|
let without_lf = line.strip_suffix('\n').unwrap_or(line);
|
||||||
|
without_lf.strip_suffix('\r').unwrap_or(without_lf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cat_numbered_line(line: &str) -> Option<(u32, &str)> {
|
||||||
|
let (prefix, text) = line.split_once('\t')?;
|
||||||
|
let number = prefix.trim();
|
||||||
|
if number.is_empty()
|
||||||
|
|| !prefix
|
||||||
|
.chars()
|
||||||
|
.all(|character| character == ' ' || character.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((number.parse().ok()?, text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_cat_numbered_code_block(
|
||||||
|
parsed: ParsedCatNumberedCode,
|
||||||
|
language: Option<Arc<Language>>,
|
||||||
|
markdown_style: MarkdownStyle,
|
||||||
|
copy_button_id: String,
|
||||||
|
cx: &App,
|
||||||
|
) -> AnyElement {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
let ParsedCatNumberedCode {
|
||||||
|
code,
|
||||||
|
first_number,
|
||||||
|
line_count,
|
||||||
|
} = parsed;
|
||||||
|
|
||||||
|
// Line numbers are contiguous (verified during parsing), so the largest
|
||||||
|
// line number is `first_number + line_count - 1`. Sizing the gutter to
|
||||||
|
// that number's digit count means every rendered line contributes exactly
|
||||||
|
// `gutter_width` bytes to the gutter, plus a newline between adjacent
|
||||||
|
// lines.
|
||||||
|
let last_number = first_number
|
||||||
|
.saturating_add(u32::try_from(line_count.saturating_sub(1)).unwrap_or(u32::MAX));
|
||||||
|
let gutter_width = last_number.to_string().len().max(1);
|
||||||
|
let gutter_capacity = line_count * gutter_width + line_count.saturating_sub(1);
|
||||||
|
|
||||||
|
let mut gutter = String::with_capacity(gutter_capacity);
|
||||||
|
for i in 0..line_count {
|
||||||
|
if i > 0 {
|
||||||
|
gutter.push('\n');
|
||||||
|
}
|
||||||
|
let line_number = first_number.saturating_add(u32::try_from(i).unwrap_or(u32::MAX));
|
||||||
|
// Writes to a `String` are infallible, so the `Result` can be ignored.
|
||||||
|
let _ = write!(&mut gutter, "{line_number:>gutter_width$}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut code_text_style = markdown_style.base_text_style.clone();
|
||||||
|
code_text_style.refine(&markdown_style.code_block.text);
|
||||||
|
|
||||||
|
let mut gutter_text_style = code_text_style.clone();
|
||||||
|
gutter_text_style.color = cx.theme().colors().text_muted;
|
||||||
|
|
||||||
|
let gutter_len = gutter.len();
|
||||||
|
let gutter = StyledText::new(gutter).with_runs(vec![gutter_text_style.to_run(gutter_len)]);
|
||||||
|
|
||||||
|
// Share `code` between syntax highlighting, the rendered `StyledText`, and
|
||||||
|
// the copy button via a single `SharedString` (cheap `Arc` clones) instead
|
||||||
|
// of cloning the underlying `String`.
|
||||||
|
let code: SharedString = code.into();
|
||||||
|
let code_runs = highlight_code_runs(&code, language.as_ref(), code_text_style, &markdown_style);
|
||||||
|
let code_text = StyledText::new(code.clone()).with_runs(code_runs);
|
||||||
|
|
||||||
|
let code_block_id = format!("read-file-code-block-{copy_button_id}");
|
||||||
|
let code_scroll_id = format!("read-file-code-scroll-{copy_button_id}");
|
||||||
|
let mut container = div()
|
||||||
|
.id(code_block_id)
|
||||||
|
.group("read-file-code-block")
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.whitespace_nowrap();
|
||||||
|
container.style().refine(&markdown_style.code_block);
|
||||||
|
|
||||||
|
// `overflow_x_scroll` only actually scrolls when the container is laid out
|
||||||
|
// as a flex container: in GPUI the default `Display` is `Block`, and a
|
||||||
|
// block-level child fills its parent's content width instead of overflowing
|
||||||
|
// it, so there is nothing for the scroll viewport to scroll. Using `flex()`
|
||||||
|
// on the scroll wrapper plus `flex_none()` on the inner item lets the inner
|
||||||
|
// item take its natural width (the unwrapped code), which is what overflows.
|
||||||
|
// `restrict_scroll_to_axis` then keeps vertical wheel events flowing through
|
||||||
|
// to the outer thread scroller. This mirrors the standard markdown
|
||||||
|
// code-block path in `crates/markdown/src/markdown.rs`.
|
||||||
|
let mut code_scroll = div()
|
||||||
|
.id(code_scroll_id)
|
||||||
|
.flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.overflow_x_scroll()
|
||||||
|
.child(div().flex_none().child(code_text));
|
||||||
|
code_scroll.style().restrict_scroll_to_axis = Some(true);
|
||||||
|
|
||||||
|
container
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.items_start()
|
||||||
|
.min_w_0()
|
||||||
|
.w_full()
|
||||||
|
.child(div().flex_none().pr_3().child(gutter))
|
||||||
|
.child(code_scroll),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.w_4()
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.right_0()
|
||||||
|
.justify_end()
|
||||||
|
.visible_on_hover("read-file-code-block")
|
||||||
|
.child(CopyButton::new(copy_button_id, code).tooltip_label("Copy Code")),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn highlight_code_runs(
|
||||||
|
code: &str,
|
||||||
|
language: Option<&Arc<Language>>,
|
||||||
|
code_text_style: TextStyle,
|
||||||
|
markdown_style: &MarkdownStyle,
|
||||||
|
) -> Vec<TextRun> {
|
||||||
|
if code.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(language) = language else {
|
||||||
|
return vec![code_text_style.to_run(code.len())];
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut runs = Vec::new();
|
||||||
|
let mut offset = 0;
|
||||||
|
for (range, highlight_id) in language.highlight_text(&Rope::from(code), 0..code.len()) {
|
||||||
|
if range.start > offset {
|
||||||
|
runs.push(code_text_style.to_run(range.start - offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut run_style = code_text_style.clone();
|
||||||
|
if let Some(highlight) = markdown_style.syntax.get(highlight_id).cloned() {
|
||||||
|
run_style = run_style.highlight(highlight);
|
||||||
|
}
|
||||||
|
runs.push(run_style.to_run(range.len()));
|
||||||
|
offset = range.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset < code.len() {
|
||||||
|
runs.push(code_text_style.to_run(code.len() - offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
runs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod numbered_code_block_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cat_numbered_markdown_code_block() {
|
||||||
|
let parsed = parse_cat_numbered_markdown_code_block(
|
||||||
|
"```rs zed/crates/example.rs\n 2\tfn main() {\n 3\t println!(\"hi\");\n 4\t}\n```\n",
|
||||||
|
)
|
||||||
|
.expect("cat-numbered block should parse");
|
||||||
|
|
||||||
|
assert_eq!(parsed.line_count, 3);
|
||||||
|
assert_eq!(parsed.first_number, 2);
|
||||||
|
assert_eq!(parsed.code, "fn main() {\n println!(\"hi\");\n}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cat_numbered_code_with_crlf_line_endings() {
|
||||||
|
let parsed = parse_cat_numbered_code(" 1\tline one\r\n 2\tline two\r\n")
|
||||||
|
.expect("crlf-terminated cat-numbered code should parse");
|
||||||
|
|
||||||
|
assert_eq!(parsed.line_count, 2);
|
||||||
|
assert_eq!(parsed.first_number, 1);
|
||||||
|
assert_eq!(parsed.code, "line one\nline two");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_cat_numbered_code_block() {
|
||||||
|
assert!(parse_cat_numbered_markdown_code_block("```rs\nfn main() {}\n```\n").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_contiguous_cat_numbers() {
|
||||||
|
assert!(
|
||||||
|
parse_cat_numbered_markdown_code_block(
|
||||||
|
"```rs\n 2\tlet a = 1;\n 4\tlet b = 2;\n```\n"
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tracks the user's permission dropdown selection state for a specific tool call.
|
/// Tracks the user's permission dropdown selection state for a specific tool call.
|
||||||
///
|
///
|
||||||
/// Default (no entry in the map) means the last dropdown choice is selected,
|
/// Default (no entry in the map) means the last dropdown choice is selected,
|
||||||
|
|
@ -367,6 +634,17 @@ pub struct TurnFields {
|
||||||
pub turn_tokens: Option<u64>,
|
pub turn_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a tool call is rendered relative to its surroundings.
|
||||||
|
///
|
||||||
|
/// `Standalone` draws its own border/margin/location header. `Embedded` is
|
||||||
|
/// hosted by a container that provides its own framing (e.g. the subagent
|
||||||
|
/// card or the main-agent awaiting-permission row).
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||||
|
enum ToolCallLayout {
|
||||||
|
Standalone,
|
||||||
|
Embedded,
|
||||||
|
}
|
||||||
|
|
||||||
impl ThreadView {
|
impl ThreadView {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
root_thread_id: ThreadId,
|
root_thread_id: ThreadId,
|
||||||
|
|
@ -2283,13 +2561,15 @@ impl ThreadView {
|
||||||
let plan = thread.plan();
|
let plan = thread.plan();
|
||||||
let queue_is_empty = !self.has_queued_messages();
|
let queue_is_empty = !self.has_queued_messages();
|
||||||
|
|
||||||
let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx);
|
let awaiting_permission = self
|
||||||
let has_subagents_awaiting = subagents_awaiting_permission.is_some();
|
.render_main_agent_awaiting_permission(window, cx)
|
||||||
|
.or_else(|| self.render_subagents_awaiting_permission(cx));
|
||||||
|
let has_awaiting_permission = awaiting_permission.is_some();
|
||||||
|
|
||||||
if changed_buffers.is_empty()
|
if changed_buffers.is_empty()
|
||||||
&& plan.is_empty()
|
&& plan.is_empty()
|
||||||
&& queue_is_empty
|
&& queue_is_empty
|
||||||
&& !has_subagents_awaiting
|
&& !has_awaiting_permission
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -2329,11 +2609,9 @@ impl ThreadView {
|
||||||
blur_radius: px(2.),
|
blur_radius: px(2.),
|
||||||
spread_radius: px(0.),
|
spread_radius: px(0.),
|
||||||
}])
|
}])
|
||||||
.when_some(subagents_awaiting_permission, |this, element| {
|
.when_some(awaiting_permission, |this, element| this.child(element))
|
||||||
this.child(element)
|
|
||||||
})
|
|
||||||
.when(
|
.when(
|
||||||
has_subagents_awaiting
|
has_awaiting_permission
|
||||||
&& (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty),
|
&& (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty),
|
||||||
|this| this.child(Divider::horizontal().color(DividerColor::Border)),
|
|this| this.child(Divider::horizontal().color(DividerColor::Border)),
|
||||||
)
|
)
|
||||||
|
|
@ -2723,6 +3001,93 @@ impl ThreadView {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true when the entry has been measured and sits entirely below
|
||||||
|
/// the current viewport.
|
||||||
|
fn entry_is_below_viewport(&self, entry_ix: usize) -> bool {
|
||||||
|
let viewport_bounds = self.list_state.viewport_bounds();
|
||||||
|
self.list_state
|
||||||
|
.bounds_for_item(entry_ix)
|
||||||
|
.is_some_and(|entry_bounds| entry_bounds.top() >= viewport_bounds.bottom())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_main_agent_awaiting_permission(
|
||||||
|
&self,
|
||||||
|
window: &Window,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> Option<AnyElement> {
|
||||||
|
if self.is_subagent() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_session_id = self.thread.read(cx).session_id().clone();
|
||||||
|
let conversation = self.conversation.read(cx);
|
||||||
|
let tool_call_id = conversation.pending_tool_call_for_session(&active_session_id, cx)?;
|
||||||
|
let pending_count = conversation.pending_tool_call_count_for_session(&active_session_id);
|
||||||
|
|
||||||
|
let thread = self.thread.read(cx);
|
||||||
|
let (entry_ix, tool_call) = thread.tool_call(&tool_call_id)?;
|
||||||
|
|
||||||
|
if !self.entry_is_below_viewport(entry_ix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let focus_handle = self.focus_handle(cx);
|
||||||
|
|
||||||
|
let card = self.render_any_tool_call(
|
||||||
|
&active_session_id,
|
||||||
|
entry_ix,
|
||||||
|
tool_call,
|
||||||
|
&focus_handle,
|
||||||
|
ToolCallLayout::Embedded,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
let label: SharedString = if pending_count > 1 {
|
||||||
|
format!("Awaiting Confirmation ({pending_count})").into()
|
||||||
|
} else {
|
||||||
|
"Awaiting Confirmation".into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = h_flex()
|
||||||
|
.p_1p5()
|
||||||
|
.pl_2()
|
||||||
|
.w_full()
|
||||||
|
.gap_1p5()
|
||||||
|
.justify_between()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().colors().border)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1p5()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.w_2()
|
||||||
|
.justify_center()
|
||||||
|
.child(GeneratingSpinnerElement::new(SpinnerVariant::Sand)),
|
||||||
|
)
|
||||||
|
.child(Label::new(label).size(LabelSize::Small).color(Color::Muted)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("main-agent-permission-scroll-to", "Scroll")
|
||||||
|
.label_size(LabelSize::Small)
|
||||||
|
.end_icon(
|
||||||
|
Icon::new(IconName::ArrowDown)
|
||||||
|
.size(IconSize::XSmall)
|
||||||
|
.color(Color::Default),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(move |this, _, _, cx| {
|
||||||
|
this.list_state.scroll_to(ListOffset {
|
||||||
|
item_ix: entry_ix,
|
||||||
|
offset_in_item: px(0.0),
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(v_flex().child(header).child(card).into_any())
|
||||||
|
}
|
||||||
|
|
||||||
fn render_message_queue_summary(
|
fn render_message_queue_summary(
|
||||||
&self,
|
&self,
|
||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
|
|
@ -4864,7 +5229,7 @@ impl ThreadView {
|
||||||
entry_ix,
|
entry_ix,
|
||||||
tool_call,
|
tool_call,
|
||||||
&self.focus_handle(cx),
|
&self.focus_handle(cx),
|
||||||
false,
|
ToolCallLayout::Standalone,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|
@ -6065,7 +6430,7 @@ impl ThreadView {
|
||||||
terminal: &Entity<acp_thread::Terminal>,
|
terminal: &Entity<acp_thread::Terminal>,
|
||||||
tool_call: &ToolCall,
|
tool_call: &ToolCall,
|
||||||
focus_handle: &FocusHandle,
|
focus_handle: &FocusHandle,
|
||||||
is_subagent: bool,
|
layout: ToolCallLayout,
|
||||||
window: &Window,
|
window: &Window,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
|
@ -6282,7 +6647,7 @@ impl ThreadView {
|
||||||
.and_then(|entry| entry.terminal(terminal));
|
.and_then(|entry| entry.terminal(terminal));
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.when(!is_subagent, |this| {
|
.when(layout == ToolCallLayout::Standalone, |this| {
|
||||||
this.my_1p5()
|
this.my_1p5()
|
||||||
.mx_5()
|
.mx_5()
|
||||||
.border_1()
|
.border_1()
|
||||||
|
|
@ -6367,7 +6732,7 @@ impl ThreadView {
|
||||||
entry_ix: usize,
|
entry_ix: usize,
|
||||||
tool_call: &ToolCall,
|
tool_call: &ToolCall,
|
||||||
focus_handle: &FocusHandle,
|
focus_handle: &FocusHandle,
|
||||||
is_subagent: bool,
|
layout: ToolCallLayout,
|
||||||
window: &Window,
|
window: &Window,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> Div {
|
) -> Div {
|
||||||
|
|
@ -6397,7 +6762,7 @@ impl ThreadView {
|
||||||
terminal,
|
terminal,
|
||||||
tool_call,
|
tool_call,
|
||||||
focus_handle,
|
focus_handle,
|
||||||
is_subagent,
|
layout,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
|
|
@ -6408,7 +6773,7 @@ impl ThreadView {
|
||||||
entry_ix,
|
entry_ix,
|
||||||
tool_call,
|
tool_call,
|
||||||
focus_handle,
|
focus_handle,
|
||||||
is_subagent,
|
layout,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
|
|
@ -6422,7 +6787,7 @@ impl ThreadView {
|
||||||
entry_ix: usize,
|
entry_ix: usize,
|
||||||
tool_call: &ToolCall,
|
tool_call: &ToolCall,
|
||||||
focus_handle: &FocusHandle,
|
focus_handle: &FocusHandle,
|
||||||
is_subagent: bool,
|
layout: ToolCallLayout,
|
||||||
window: &Window,
|
window: &Window,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> Div {
|
) -> Div {
|
||||||
|
|
@ -6670,7 +7035,7 @@ impl ThreadView {
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
if is_subagent {
|
if layout == ToolCallLayout::Embedded {
|
||||||
this
|
this
|
||||||
} else if use_card_layout {
|
} else if use_card_layout {
|
||||||
this.my_1p5()
|
this.my_1p5()
|
||||||
|
|
@ -6684,7 +7049,7 @@ impl ThreadView {
|
||||||
this.my_1()
|
this.my_1()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when(!is_subagent, |this| {
|
.when(layout == ToolCallLayout::Standalone, |this| {
|
||||||
this.map(|this| {
|
this.map(|this| {
|
||||||
if has_location && !use_card_layout {
|
if has_location && !use_card_layout {
|
||||||
this.ml_4()
|
this.ml_4()
|
||||||
|
|
@ -7655,7 +8020,9 @@ impl ThreadView {
|
||||||
} else if let Some(markdown) = content.markdown() {
|
} else if let Some(markdown) = content.markdown() {
|
||||||
self.render_markdown_output(
|
self.render_markdown_output(
|
||||||
markdown.clone(),
|
markdown.clone(),
|
||||||
|
entry_ix,
|
||||||
context_ix,
|
context_ix,
|
||||||
|
tool_call,
|
||||||
card_layout,
|
card_layout,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
|
|
@ -7683,7 +8050,7 @@ impl ThreadView {
|
||||||
terminal,
|
terminal,
|
||||||
tool_call,
|
tool_call,
|
||||||
focus_handle,
|
focus_handle,
|
||||||
false,
|
ToolCallLayout::Standalone,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
),
|
),
|
||||||
|
|
@ -7798,11 +8165,28 @@ impl ThreadView {
|
||||||
fn render_markdown_output(
|
fn render_markdown_output(
|
||||||
&self,
|
&self,
|
||||||
markdown: Entity<Markdown>,
|
markdown: Entity<Markdown>,
|
||||||
|
entry_ix: usize,
|
||||||
context_ix: usize,
|
context_ix: usize,
|
||||||
|
tool_call: &ToolCall,
|
||||||
card_layout: bool,
|
card_layout: bool,
|
||||||
window: &Window,
|
window: &Window,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
let markdown_style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
|
||||||
|
let output = self
|
||||||
|
.render_numbered_read_file_output(
|
||||||
|
markdown.clone(),
|
||||||
|
entry_ix,
|
||||||
|
context_ix,
|
||||||
|
tool_call,
|
||||||
|
markdown_style.clone(),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
self.render_markdown(markdown, markdown_style, cx)
|
||||||
|
.into_any()
|
||||||
|
});
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.map(|this| {
|
.map(|this| {
|
||||||
|
|
@ -7820,14 +8204,39 @@ impl ThreadView {
|
||||||
})
|
})
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().colors().text_muted)
|
.text_color(cx.theme().colors().text_muted)
|
||||||
.child(self.render_markdown(
|
.child(output)
|
||||||
markdown,
|
|
||||||
MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_numbered_read_file_output(
|
||||||
|
&self,
|
||||||
|
markdown: Entity<Markdown>,
|
||||||
|
entry_ix: usize,
|
||||||
|
context_ix: usize,
|
||||||
|
tool_call: &ToolCall,
|
||||||
|
markdown_style: MarkdownStyle,
|
||||||
|
cx: &Context<Self>,
|
||||||
|
) -> Option<AnyElement> {
|
||||||
|
let is_read_file = tool_call
|
||||||
|
.tool_name
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|tool_name| tool_name.as_ref() == "read_file");
|
||||||
|
if !is_read_file {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let markdown = markdown.read(cx);
|
||||||
|
let parsed = parse_cat_numbered_markdown_code_block(markdown.source())?;
|
||||||
|
let language = markdown.first_code_block_language();
|
||||||
|
Some(render_cat_numbered_code_block(
|
||||||
|
parsed,
|
||||||
|
language,
|
||||||
|
markdown_style,
|
||||||
|
format!("copy-read-file-output-{entry_ix}-{context_ix}"),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn render_image_output(
|
fn render_image_output(
|
||||||
&self,
|
&self,
|
||||||
entry_ix: usize,
|
entry_ix: usize,
|
||||||
|
|
@ -8220,7 +8629,7 @@ impl ThreadView {
|
||||||
entry_ix,
|
entry_ix,
|
||||||
tool_call,
|
tool_call,
|
||||||
focus_handle,
|
focus_handle,
|
||||||
true,
|
ToolCallLayout::Embedded,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -891,6 +891,7 @@ impl Database {
|
||||||
branch_summary,
|
branch_summary,
|
||||||
head_commit_details,
|
head_commit_details,
|
||||||
branch_list: Vec::new(),
|
branch_list: Vec::new(),
|
||||||
|
branch_list_error: None,
|
||||||
scan_id: db_repository_entry.scan_id as u64,
|
scan_id: db_repository_entry.scan_id as u64,
|
||||||
is_last_update: true,
|
is_last_update: true,
|
||||||
merge_message: db_repository_entry.merge_message,
|
merge_message: db_repository_entry.merge_message,
|
||||||
|
|
|
||||||
|
|
@ -791,6 +791,7 @@ impl Database {
|
||||||
branch_summary,
|
branch_summary,
|
||||||
head_commit_details,
|
head_commit_details,
|
||||||
branch_list: Vec::new(),
|
branch_list: Vec::new(),
|
||||||
|
branch_list_error: None,
|
||||||
project_id: project_id.to_proto(),
|
project_id: project_id.to_proto(),
|
||||||
id: db_repository.id as u64,
|
id: db_repository.id as u64,
|
||||||
abs_path: db_repository.abs_path.clone(),
|
abs_path: db_repository.abs_path.clone(),
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ use settings::SettingsStore;
|
||||||
use text::{Point, ToPoint};
|
use text::{Point, ToPoint};
|
||||||
use util::{path, rel_path::rel_path, test::sample_text};
|
use util::{path, rel_path::rel_path, test::sample_text};
|
||||||
use workspace::{
|
use workspace::{
|
||||||
CloseWindow, CollaboratorId, MultiWorkspace, ParticipantLocation, SplitDirection, Workspace,
|
CloseWindow, CollaboratorId, Item, MultiWorkspace, ParticipantLocation, SplitDirection,
|
||||||
item::ItemHandle as _,
|
Workspace, item::ItemHandle as _,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::TestClient;
|
use super::TestClient;
|
||||||
|
|
@ -154,7 +154,7 @@ async fn test_basic_following(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cx_b.read(|cx| editor_b2.project_path(cx)),
|
cx_b.read(|cx| editor_b2.read(cx).active_project_path(cx)),
|
||||||
Some((worktree_id, rel_path("2.txt")).into())
|
Some((worktree_id, rel_path("2.txt")).into())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1866,7 +1866,7 @@ async fn test_following_into_excluded_file(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cx_b.read(|cx| editor_for_excluded_b.project_path(cx)),
|
cx_b.read(|cx| editor_for_excluded_b.read(cx).active_project_path(cx)),
|
||||||
Some((worktree_id, rel_path(".git/COMMIT_EDITMSG")).into())
|
Some((worktree_id, rel_path(".git/COMMIT_EDITMSG")).into())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -7142,6 +7142,7 @@ async fn test_remote_git_branches(
|
||||||
let new_branch = branches[2];
|
let new_branch = branches[2];
|
||||||
|
|
||||||
let branches_b = branches_b
|
let branches_b = branches_b
|
||||||
|
.branches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|branch| branch.name().to_string())
|
.map(|branch| branch.name().to_string())
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,7 @@ async fn test_ssh_collaboration_git_branches(
|
||||||
let new_branch = branches[2];
|
let new_branch = branches[2];
|
||||||
|
|
||||||
let branches_b = branches_b
|
let branches_b = branches_b
|
||||||
|
.branches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|branch| branch.name().to_string())
|
.map(|branch| branch.name().to_string())
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
|
|
|
||||||
|
|
@ -1962,7 +1962,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view(
|
||||||
.read_with(cx, |_multi, cx| {
|
.read_with(cx, |_multi, cx| {
|
||||||
let active = pane_a.read(cx).active_item().unwrap();
|
let active = pane_a.read(cx).active_item().unwrap();
|
||||||
let editor = active.to_any_view().downcast::<Editor>().unwrap();
|
let editor = active.to_any_view().downcast::<Editor>().unwrap();
|
||||||
let path = editor.read(cx).project_path(cx).unwrap();
|
let path = editor.read(cx).active_project_path(cx).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path.path.file_name().unwrap(),
|
path.path.file_name().unwrap(),
|
||||||
"second.rs",
|
"second.rs",
|
||||||
|
|
@ -1976,7 +1976,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view(
|
||||||
.read_with(cx, |_multi, cx| {
|
.read_with(cx, |_multi, cx| {
|
||||||
let active = pane_b.read(cx).active_item().unwrap();
|
let active = pane_b.read(cx).active_item().unwrap();
|
||||||
let editor = active.to_any_view().downcast::<Editor>().unwrap();
|
let editor = active.to_any_view().downcast::<Editor>().unwrap();
|
||||||
let path = editor.read(cx).project_path(cx).unwrap();
|
let path = editor.read(cx).active_project_path(cx).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path.path.file_name().unwrap(),
|
path.path.file_name().unwrap(),
|
||||||
"main.rs",
|
"main.rs",
|
||||||
|
|
@ -2056,7 +2056,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view(
|
||||||
.read_with(cx, |_multi, cx| {
|
.read_with(cx, |_multi, cx| {
|
||||||
let pane_a_active = pane_a.read(cx).active_item().unwrap();
|
let pane_a_active = pane_a.read(cx).active_item().unwrap();
|
||||||
let pane_a_editor = pane_a_active.to_any_view().downcast::<Editor>().unwrap();
|
let pane_a_editor = pane_a_active.to_any_view().downcast::<Editor>().unwrap();
|
||||||
let pane_a_path = pane_a_editor.read(cx).project_path(cx).unwrap();
|
let pane_a_path = pane_a_editor.read(cx).active_project_path(cx).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pane_a_path.path.file_name().unwrap(),
|
pane_a_path.path.file_name().unwrap(),
|
||||||
"second.rs",
|
"second.rs",
|
||||||
|
|
@ -2161,7 +2161,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view(
|
||||||
.read_with(cx, |_multi, cx| {
|
.read_with(cx, |_multi, cx| {
|
||||||
let pane_b_active = pane_b.read(cx).active_item().unwrap();
|
let pane_b_active = pane_b.read(cx).active_item().unwrap();
|
||||||
let pane_b_editor = pane_b_active.to_any_view().downcast::<Editor>().unwrap();
|
let pane_b_editor = pane_b_active.to_any_view().downcast::<Editor>().unwrap();
|
||||||
let pane_b_path = pane_b_editor.read(cx).project_path(cx).unwrap();
|
let pane_b_path = pane_b_editor.read(cx).active_project_path(cx).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pane_b_path.path.file_name().unwrap(),
|
pane_b_path.path.file_name().unwrap(),
|
||||||
"second.rs",
|
"second.rs",
|
||||||
|
|
@ -2232,7 +2232,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view(
|
||||||
.read_with(cx, |_multi, cx| {
|
.read_with(cx, |_multi, cx| {
|
||||||
let pane_c_active = pane_c.read(cx).active_item().unwrap();
|
let pane_c_active = pane_c.read(cx).active_item().unwrap();
|
||||||
let pane_c_editor = pane_c_active.to_any_view().downcast::<Editor>().unwrap();
|
let pane_c_editor = pane_c_active.to_any_view().downcast::<Editor>().unwrap();
|
||||||
let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap();
|
let pane_c_path = pane_c_editor.read(cx).active_project_path(cx).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pane_c_path.path.file_name().unwrap(),
|
pane_c_path.path.file_name().unwrap(),
|
||||||
"second.rs",
|
"second.rs",
|
||||||
|
|
@ -2294,7 +2294,7 @@ async fn test_breakpoint_jumps_only_in_proper_split_view(
|
||||||
.read_with(cx, |_multi, cx| {
|
.read_with(cx, |_multi, cx| {
|
||||||
let pane_c_active = pane_c.read(cx).active_item().unwrap();
|
let pane_c_active = pane_c.read(cx).active_item().unwrap();
|
||||||
let pane_c_editor = pane_c_active.to_any_view().downcast::<Editor>().unwrap();
|
let pane_c_editor = pane_c_active.to_any_view().downcast::<Editor>().unwrap();
|
||||||
let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap();
|
let pane_c_path = pane_c_editor.read(cx).active_project_path(cx).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pane_c_path.path.file_name().unwrap(),
|
pane_c_path.path.file_name().unwrap(),
|
||||||
"main.rs",
|
"main.rs",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use unindent::Unindent as _;
|
use unindent::Unindent as _;
|
||||||
use util::{path, rel_path::rel_path};
|
use util::{path, rel_path::rel_path};
|
||||||
|
use workspace::Item;
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_fetch_initial_stack_frames_and_go_to_stack_frame(
|
async fn test_fetch_initial_stack_frames_and_go_to_stack_frame(
|
||||||
|
|
@ -334,7 +335,7 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC
|
||||||
assert_eq!(1, editors.len());
|
assert_eq!(1, editors.len());
|
||||||
|
|
||||||
let project_path = editors[0]
|
let project_path = editors[0]
|
||||||
.update(cx, |editor, cx| editor.project_path(cx))
|
.update(cx, |editor, cx| editor.active_project_path(cx))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rel_path("src/test.js"), project_path.path.as_ref());
|
assert_eq!(rel_path("src/test.js"), project_path.path.as_ref());
|
||||||
assert_eq!(test_file_content, editors[0].read(cx).text(cx));
|
assert_eq!(test_file_content, editors[0].read(cx).text(cx));
|
||||||
|
|
@ -397,7 +398,7 @@ async fn test_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppC
|
||||||
assert_eq!(1, editors.len());
|
assert_eq!(1, editors.len());
|
||||||
|
|
||||||
let project_path = editors[0]
|
let project_path = editors[0]
|
||||||
.update(cx, |editor, cx| editor.project_path(cx))
|
.update(cx, |editor, cx| editor.active_project_path(cx))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rel_path("src/module.js"), project_path.path.as_ref());
|
assert_eq!(rel_path("src/module.js"), project_path.path.as_ref());
|
||||||
assert_eq!(module_file_content, editors[0].read(cx).text(cx));
|
assert_eq!(module_file_content, editors[0].read(cx).text(cx));
|
||||||
|
|
|
||||||
|
|
@ -319,10 +319,7 @@ pub async fn stream_completion(
|
||||||
if line == "[DONE]" {
|
if line == "[DONE]" {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
match serde_json::from_str(line) {
|
Some(serde_json::from_str(line).map_err(Into::into))
|
||||||
Ok(response) => Some(Ok(response)),
|
|
||||||
Err(error) => Some(Err(anyhow!(error))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => Some(Err(anyhow!(error))),
|
Err(error) => Some(Err(anyhow!(error))),
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ impl BufferDiagnosticsEditor {
|
||||||
// If there's no active editor with a project path, avoiding deploying
|
// If there's no active editor with a project path, avoiding deploying
|
||||||
// the buffer diagnostics view.
|
// the buffer diagnostics view.
|
||||||
if let Some(editor) = workspace.active_item_as::<Editor>(cx)
|
if let Some(editor) = workspace.active_item_as::<Editor>(cx)
|
||||||
&& let Some(project_path) = editor.project_path(cx)
|
&& let Some(project_path) = editor.read(cx).active_project_path(cx)
|
||||||
{
|
{
|
||||||
// Check if there's already a `BufferDiagnosticsEditor` tab for this
|
// Check if there's already a `BufferDiagnosticsEditor` tab for this
|
||||||
// same path, and if so, focus on that one instead of creating a new
|
// same path, and if so, focus on that one instead of creating a new
|
||||||
|
|
@ -749,6 +749,10 @@ impl Item for BufferDiagnosticsEditor {
|
||||||
self.editor.for_each_project_item(cx, f);
|
self.editor.for_each_project_item(cx, f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, _cx: &App) -> Option<ProjectPath> {
|
||||||
|
Some(self.project_path.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn has_conflict(&self, cx: &App) -> bool {
|
fn has_conflict(&self, cx: &App) -> bool {
|
||||||
self.multibuffer.read(cx).has_conflict(cx)
|
self.multibuffer.read(cx).has_conflict(cx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,10 @@ impl Item for ProjectDiagnosticsEditor {
|
||||||
self.editor.for_each_project_item(cx, f)
|
self.editor.for_each_project_item(cx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(
|
fn set_nav_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
nav_history: ItemNavHistory,
|
nav_history: ItemNavHistory,
|
||||||
|
|
|
||||||
|
|
@ -651,7 +651,7 @@ pub fn parse_order_spec(spec: &str) -> Vec<BTreeSet<usize>> {
|
||||||
order
|
order
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct EditLocation {
|
pub struct EditLocation {
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub source_line_number: usize,
|
pub source_line_number: usize,
|
||||||
|
|
@ -667,8 +667,8 @@ pub enum EditType {
|
||||||
Insertion,
|
Insertion,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn locate_edited_line(patch: &Patch, mut edit_index: isize) -> Option<EditLocation> {
|
pub fn edit_locations(patch: &Patch) -> Vec<EditLocation> {
|
||||||
let mut edit_locations = vec![];
|
let mut edit_locations = Vec::new();
|
||||||
|
|
||||||
for (hunk_index, hunk) in patch.hunks.iter().enumerate() {
|
for (hunk_index, hunk) in patch.hunks.iter().enumerate() {
|
||||||
let mut old_line_number = hunk.old_start;
|
let mut old_line_number = hunk.old_start;
|
||||||
|
|
@ -724,6 +724,12 @@ pub fn locate_edited_line(patch: &Patch, mut edit_index: isize) -> Option<EditLo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
edit_locations
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn locate_edited_line(patch: &Patch, mut edit_index: isize) -> Option<EditLocation> {
|
||||||
|
let mut edit_locations = edit_locations(patch);
|
||||||
|
|
||||||
if edit_index < 0 {
|
if edit_index < 0 {
|
||||||
edit_index += edit_locations.len() as isize; // take from end
|
edit_index += edit_locations.len() as isize; // take from end
|
||||||
}
|
}
|
||||||
|
|
@ -845,6 +851,8 @@ mod tests {
|
||||||
-zinc
|
-zinc
|
||||||
"};
|
"};
|
||||||
let patch = Patch::parse_unified_diff(patch_str);
|
let patch = Patch::parse_unified_diff(patch_str);
|
||||||
|
let locations = edit_locations(&patch);
|
||||||
|
assert_eq!(locations.len(), 4);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
locate_edited_line(&patch, 0), // -blue
|
locate_edited_line(&patch, 0), // -blue
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
//!
|
//!
|
||||||
//! TODO: Port Python code to generate chronologically-ordered commits
|
//! TODO: Port Python code to generate chronologically-ordered commits
|
||||||
use crate::FailedHandling;
|
use crate::FailedHandling;
|
||||||
use crate::reorder_patch::{Patch, PatchLine, extract_edits, locate_edited_line};
|
use crate::reorder_patch::{Patch, PatchLine, edit_locations, extract_edits, locate_edited_line};
|
||||||
use crate::word_diff::tokenize;
|
use crate::word_diff::tokenize;
|
||||||
|
|
||||||
/// Find the largest valid UTF-8 char boundary at or before `index` in `s`.
|
/// Find the largest valid UTF-8 char boundary at or before `index` in `s`.
|
||||||
|
|
@ -27,7 +27,7 @@ use clap::Args;
|
||||||
use edit_prediction::example_spec::ExampleSpec;
|
use edit_prediction::example_spec::ExampleSpec;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use similar::{DiffTag, TextDiff};
|
use similar::{DiffTag, TextDiff};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
@ -35,6 +35,8 @@ use std::io::{self, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const MAX_SPLIT_POINT_SAMPLING_ATTEMPTS: usize = 10;
|
||||||
|
|
||||||
/// `ep split-commit` CLI args.
|
/// `ep split-commit` CLI args.
|
||||||
#[derive(Debug, Args, Clone)]
|
#[derive(Debug, Args, Clone)]
|
||||||
pub struct SplitCommitArgs {
|
pub struct SplitCommitArgs {
|
||||||
|
|
@ -74,11 +76,12 @@ pub struct AnnotatedCommit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cursor position in a file.
|
/// Cursor position in a file.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CursorPosition {
|
pub struct CursorPosition {
|
||||||
pub file: String,
|
pub file: String,
|
||||||
pub line: usize,
|
pub line: usize,
|
||||||
pub column: usize,
|
pub column: usize,
|
||||||
|
pub line_length: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for CursorPosition {
|
impl std::fmt::Display for CursorPosition {
|
||||||
|
|
@ -111,6 +114,89 @@ fn parse_split_point(value: &str) -> Option<SplitPoint> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_service_file(path: &str) -> bool {
|
||||||
|
let path = path.trim();
|
||||||
|
let path = path
|
||||||
|
.strip_prefix("a/")
|
||||||
|
.or_else(|| path.strip_prefix("b/"))
|
||||||
|
.unwrap_or(path)
|
||||||
|
.trim_start_matches("./");
|
||||||
|
|
||||||
|
if path.is_empty() || path == "/dev/null" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = path.rsplit('/').next().unwrap_or(path);
|
||||||
|
if matches!(
|
||||||
|
file_name,
|
||||||
|
"package.json"
|
||||||
|
| "package-lock.json"
|
||||||
|
| "pnpm-lock.yaml"
|
||||||
|
| "Cargo.lock"
|
||||||
|
| "yarn.lock"
|
||||||
|
| "bun.lock"
|
||||||
|
| "bun.lockb"
|
||||||
|
| "go.sum"
|
||||||
|
| "composer.lock"
|
||||||
|
| "Gemfile.lock"
|
||||||
|
| "Pipfile.lock"
|
||||||
|
| "poetry.lock"
|
||||||
|
| "uv.lock"
|
||||||
|
| ".gitlab-ci.yml"
|
||||||
|
| ".travis.yml"
|
||||||
|
| "azure-pipelines.yml"
|
||||||
|
| "Jenkinsfile"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_name.ends_with(".min.js")
|
||||||
|
|| file_name.ends_with(".bundle.js")
|
||||||
|
|| file_name.contains(".generated.")
|
||||||
|
|| file_name.ends_with(".pb.go")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == ".github/workflows"
|
||||||
|
|| path.starts_with(".github/workflows/")
|
||||||
|
|| path == ".circleci"
|
||||||
|
|| path.starts_with(".circleci/")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.split('/').any(|component| {
|
||||||
|
matches!(
|
||||||
|
component,
|
||||||
|
"dist" | "build" | "coverage" | "node_modules" | "vendor"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_starts_on_service_file(patch: &Patch, split_pos: usize) -> bool {
|
||||||
|
locate_edited_line(patch, split_pos as isize)
|
||||||
|
.is_some_and(|edit_location| is_service_file(&edit_location.filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_split_point(patch: &Patch, rng: &mut dyn rand::RngCore) -> usize {
|
||||||
|
let stats = patch.stats();
|
||||||
|
let num_edits = stats.added + stats.removed;
|
||||||
|
if num_edits == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut split = rng.random_range(1..=num_edits);
|
||||||
|
for _ in 1..MAX_SPLIT_POINT_SAMPLING_ATTEMPTS {
|
||||||
|
if !edit_starts_on_service_file(patch, split) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
split = rng.random_range(1..=num_edits);
|
||||||
|
}
|
||||||
|
|
||||||
|
split
|
||||||
|
}
|
||||||
|
|
||||||
/// Entry point for the `ep split-commit` subcommand.
|
/// Entry point for the `ep split-commit` subcommand.
|
||||||
///
|
///
|
||||||
/// This runs synchronously and outputs JSON Lines (one output per input line).
|
/// This runs synchronously and outputs JSON Lines (one output per input line).
|
||||||
|
|
@ -132,6 +218,7 @@ pub fn run_split_commit(
|
||||||
|
|
||||||
let split_point = args.split_point.as_deref().and_then(parse_split_point);
|
let split_point = args.split_point.as_deref().and_then(parse_split_point);
|
||||||
let mut output_lines = Vec::new();
|
let mut output_lines = Vec::new();
|
||||||
|
let mut processed_commits = 0usize;
|
||||||
|
|
||||||
for input_path in inputs {
|
for input_path in inputs {
|
||||||
let input: Box<dyn BufRead> = if input_path.as_os_str() == "-" {
|
let input: Box<dyn BufRead> = if input_path.as_os_str() == "-" {
|
||||||
|
|
@ -240,9 +327,23 @@ pub fn run_split_commit(
|
||||||
|
|
||||||
output_lines.push(json);
|
output_lines.push(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
processed_commits += 1;
|
||||||
|
eprint!(
|
||||||
|
"\rsplit-commit: processed {} commits, generated {} examples",
|
||||||
|
processed_commits,
|
||||||
|
output_lines.len()
|
||||||
|
);
|
||||||
|
io::stderr()
|
||||||
|
.flush()
|
||||||
|
.context("failed to flush progress to stderr")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if processed_commits > 0 {
|
||||||
|
eprintln!();
|
||||||
|
}
|
||||||
|
|
||||||
let output_content = output_lines.join("\n") + if output_lines.is_empty() { "" } else { "\n" };
|
let output_content = output_lines.join("\n") + if output_lines.is_empty() { "" } else { "\n" };
|
||||||
|
|
||||||
if let Some(path) = output_path {
|
if let Some(path) = output_path {
|
||||||
|
|
@ -302,7 +403,7 @@ pub fn generate_evaluation_example_from_ordered_commit(
|
||||||
anyhow::ensure!(num_edits != 0, "no edits found in commit");
|
anyhow::ensure!(num_edits != 0, "no edits found in commit");
|
||||||
|
|
||||||
let split = match split_point {
|
let split = match split_point {
|
||||||
None => rng.random_range(1..=num_edits),
|
None => sample_split_point(&patch, rng.as_mut()),
|
||||||
Some(SplitPoint::Fraction(f)) => {
|
Some(SplitPoint::Fraction(f)) => {
|
||||||
let v = (f * num_edits as f64).floor() as usize;
|
let v = (f * num_edits as f64).floor() as usize;
|
||||||
v.min(num_edits)
|
v.min(num_edits)
|
||||||
|
|
@ -331,7 +432,7 @@ pub fn generate_evaluation_example_from_ordered_commit(
|
||||||
// Sample cursor position
|
// Sample cursor position
|
||||||
let cursor = match cursor_opt {
|
let cursor = match cursor_opt {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => sample_cursor_position(&patch, &split_commit)
|
None => sample_cursor_position(&split_commit, rng.as_mut())
|
||||||
.context("failed to sample cursor position")?,
|
.context("failed to sample cursor position")?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -343,7 +444,8 @@ pub fn generate_evaluation_example_from_ordered_commit(
|
||||||
)
|
)
|
||||||
.context("failed to generate cursor excerpt")?;
|
.context("failed to generate cursor excerpt")?;
|
||||||
|
|
||||||
// Handle edge case where split_point == 0
|
// Where the source patch is empty, there's not enough info to make a
|
||||||
|
// meaningful prediction
|
||||||
if split == 0 {
|
if split == 0 {
|
||||||
split_commit.target_patch = String::new();
|
split_commit.target_patch = String::new();
|
||||||
}
|
}
|
||||||
|
|
@ -392,7 +494,12 @@ pub fn generate_evaluation_example_from_ordered_commit(
|
||||||
pub fn split_ordered_commit(commit: &str, split_pos: usize) -> (String, String) {
|
pub fn split_ordered_commit(commit: &str, split_pos: usize) -> (String, String) {
|
||||||
let patch = Patch::parse_unified_diff(commit);
|
let patch = Patch::parse_unified_diff(commit);
|
||||||
let source_edits: BTreeSet<usize> = (0..split_pos).collect();
|
let source_edits: BTreeSet<usize> = (0..split_pos).collect();
|
||||||
let (source, target) = extract_edits(&patch, &source_edits);
|
let (source, mut target) = extract_edits(&patch, &source_edits);
|
||||||
|
if !target.hunks.is_empty() {
|
||||||
|
if let Some(header) = header_for_edit(&patch, split_pos) {
|
||||||
|
target.header = header;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut source_str = source.to_string();
|
let mut source_str = source.to_string();
|
||||||
let target_str = target.to_string();
|
let target_str = target.to_string();
|
||||||
|
|
@ -417,23 +524,61 @@ pub fn split_ordered_commit(commit: &str, split_pos: usize) -> (String, String)
|
||||||
(source_str, target_str)
|
(source_str, target_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate the weight for a split position based on the character at that position.
|
fn header_for_edit(patch: &Patch, edit_index: usize) -> Option<String> {
|
||||||
|
let edit_index = edit_index.try_into().ok()?;
|
||||||
|
let edit_location = locate_edited_line(patch, edit_index)?;
|
||||||
|
header_for_hunk(patch, edit_location.hunk_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_for_hunk(patch: &Patch, hunk_index: usize) -> Option<String> {
|
||||||
|
for hunk in patch.hunks.get(..hunk_index)?.iter().rev() {
|
||||||
|
let mut header_lines = Vec::new();
|
||||||
|
for line in hunk.lines.iter().rev() {
|
||||||
|
let PatchLine::Garbage(line) = line else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if line.trim().is_empty() && header_lines.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !line.starts_with("//") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
header_lines.push(line.as_str());
|
||||||
|
}
|
||||||
|
if !header_lines.is_empty() {
|
||||||
|
return Some(render_reversed_header_lines(header_lines));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_lines = patch
|
||||||
|
.header
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.skip_while(|line| line.trim().is_empty())
|
||||||
|
.take_while(|line| line.starts_with("//"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(!header_lines.is_empty()).then(|| render_reversed_header_lines(header_lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_reversed_header_lines(mut lines: Vec<&str>) -> String {
|
||||||
|
lines.reverse();
|
||||||
|
lines.join("\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the weight for a split byte offset in `text`.
|
||||||
///
|
///
|
||||||
/// Higher weights indicate more natural pause points (e.g., after punctuation,
|
/// Higher weights indicate more natural pause points (e.g., after punctuation,
|
||||||
/// at identifier boundaries). Lower weights indicate less natural points
|
/// at identifier boundaries). Lower weights indicate less natural points
|
||||||
/// (e.g., mid-identifier).
|
/// (e.g., mid-identifier).
|
||||||
fn position_weight(text: &str, pos: usize) -> u32 {
|
fn position_weight(text: &str, byte_offset: usize) -> u32 {
|
||||||
if pos == 0 || pos > text.len() {
|
if byte_offset == 0 || byte_offset > text.len() || !text.is_char_boundary(byte_offset) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
let Some(prev_char) = text[..byte_offset].chars().next_back() else {
|
||||||
if pos > chars.len() {
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
};
|
||||||
|
let next_char = text[byte_offset..].chars().next();
|
||||||
// Get the character just before this position (what we just "typed")
|
|
||||||
let prev_char = chars[pos - 1];
|
|
||||||
|
|
||||||
// High weight: natural pause points (end of statement/argument, opening brackets)
|
// High weight: natural pause points (end of statement/argument, opening brackets)
|
||||||
if matches!(prev_char, ',' | ';' | ':' | '(' | '[' | '{') {
|
if matches!(prev_char, ',' | ';' | ':' | '(' | '[' | '{') {
|
||||||
|
|
@ -455,8 +600,7 @@ fn position_weight(text: &str, pos: usize) -> u32 {
|
||||||
|
|
||||||
// Check if we're at the end of an identifier (word char followed by non-word char)
|
// Check if we're at the end of an identifier (word char followed by non-word char)
|
||||||
let is_prev_word_char = prev_char.is_alphanumeric() || prev_char == '_';
|
let is_prev_word_char = prev_char.is_alphanumeric() || prev_char == '_';
|
||||||
let is_next_word_char =
|
let is_next_word_char = next_char.is_some_and(|ch| ch.is_alphanumeric() || ch == '_');
|
||||||
pos < chars.len() && (chars[pos].is_alphanumeric() || chars[pos] == '_');
|
|
||||||
|
|
||||||
if is_prev_word_char && !is_next_word_char {
|
if is_prev_word_char && !is_next_word_char {
|
||||||
// End of identifier - high weight
|
// End of identifier - high weight
|
||||||
|
|
@ -481,6 +625,7 @@ fn position_weight(text: &str, pos: usize) -> u32 {
|
||||||
///
|
///
|
||||||
/// Returns an index based on the weights, using the provided seed for
|
/// Returns an index based on the weights, using the provided seed for
|
||||||
/// deterministic selection.
|
/// deterministic selection.
|
||||||
|
#[cfg(test)]
|
||||||
fn weighted_select(weights: &[u32], seed: u64) -> usize {
|
fn weighted_select(weights: &[u32], seed: u64) -> usize {
|
||||||
if weights.is_empty() {
|
if weights.is_empty() {
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -507,6 +652,74 @@ fn weighted_select(weights: &[u32], seed: u64) -> usize {
|
||||||
weights.len() - 1
|
weights.len() - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct CandidateSplit {
|
||||||
|
edit_byte_offset: usize,
|
||||||
|
weight: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_typed_text_candidates(
|
||||||
|
candidates: &mut Vec<CandidateSplit>,
|
||||||
|
edit_start_byte_offset: usize,
|
||||||
|
final_line: &str,
|
||||||
|
final_line_start_byte_offset: usize,
|
||||||
|
typed_text: &str,
|
||||||
|
) {
|
||||||
|
for (byte_offset, character) in typed_text.char_indices() {
|
||||||
|
let next_byte_offset = byte_offset + character.len_utf8();
|
||||||
|
let final_line_candidate_byte_offset = final_line_start_byte_offset + next_byte_offset;
|
||||||
|
if final_line[..final_line_candidate_byte_offset]
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(CandidateSplit {
|
||||||
|
edit_byte_offset: edit_start_byte_offset + next_byte_offset,
|
||||||
|
weight: position_weight(final_line, final_line_candidate_byte_offset),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_deleted_text_candidates(
|
||||||
|
candidates: &mut Vec<CandidateSplit>,
|
||||||
|
edit_start_byte_offset: usize,
|
||||||
|
deleted_text: &str,
|
||||||
|
) {
|
||||||
|
for (byte_offset, character) in deleted_text.char_indices() {
|
||||||
|
candidates.push(CandidateSplit {
|
||||||
|
edit_byte_offset: edit_start_byte_offset + byte_offset + character.len_utf8(),
|
||||||
|
weight: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weighted_select_candidate(candidates: &[CandidateSplit], seed: u64) -> Option<CandidateSplit> {
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_weight: u64 = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| candidate.weight as u64)
|
||||||
|
.sum();
|
||||||
|
if total_weight == 0 {
|
||||||
|
return Some(candidates[seed as usize % candidates.len()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = seed % total_weight;
|
||||||
|
let mut cumulative: u64 = 0;
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
cumulative += candidate.weight as u64;
|
||||||
|
if target < cumulative {
|
||||||
|
return Some(*candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.last().copied()
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate similarity ratio between two strings (0-100).
|
/// Calculate similarity ratio between two strings (0-100).
|
||||||
fn fuzzy_ratio(s1: &str, s2: &str) -> u32 {
|
fn fuzzy_ratio(s1: &str, s2: &str) -> u32 {
|
||||||
if s1.is_empty() && s2.is_empty() {
|
if s1.is_empty() && s2.is_empty() {
|
||||||
|
|
@ -567,25 +780,13 @@ pub fn imitate_human_edits(
|
||||||
_ => return no_change,
|
_ => return no_change,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to locate the last edit in source
|
let source_edit_locations = edit_locations(&src_patch);
|
||||||
let src_edit_loc = locate_edited_line(&src_patch, -1);
|
let src_edit_loc = source_edit_locations.last().cloned();
|
||||||
|
|
||||||
// Check if source has ANY edit at the same line as target's first edit
|
let src_has_edit_at_target_line = source_edit_locations.iter().any(|loc| {
|
||||||
// We need to iterate through all edits to check this
|
loc.filename == tgt_edit_loc.filename
|
||||||
let src_has_edit_at_target_line = {
|
&& loc.target_line_number == tgt_edit_loc.target_line_number
|
||||||
let mut found = false;
|
});
|
||||||
let mut idx = 0isize;
|
|
||||||
while let Some(loc) = locate_edited_line(&src_patch, idx) {
|
|
||||||
if loc.filename == tgt_edit_loc.filename
|
|
||||||
&& loc.target_line_number == tgt_edit_loc.target_line_number
|
|
||||||
{
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
found
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if this is a replacement (deletion followed by insertion on the same line)
|
// Check if this is a replacement (deletion followed by insertion on the same line)
|
||||||
// or a pure insertion (no corresponding deletion in source)
|
// or a pure insertion (no corresponding deletion in source)
|
||||||
|
|
@ -623,61 +824,62 @@ pub fn imitate_human_edits(
|
||||||
// Use similar to get diff operations
|
// Use similar to get diff operations
|
||||||
let diff = TextDiff::from_slices(&src_tokens, &tgt_tokens);
|
let diff = TextDiff::from_slices(&src_tokens, &tgt_tokens);
|
||||||
|
|
||||||
// Build weights for each possible split position
|
let mut candidate_splits = Vec::new();
|
||||||
let mut position_weights: Vec<u32> = Vec::new();
|
let mut edit_byte_offset = 0usize;
|
||||||
|
let mut final_line_byte_offset = 0usize;
|
||||||
|
|
||||||
// Simulate the edit process to collect weights for all possible split positions
|
for op in diff.ops() {
|
||||||
{
|
match op.tag() {
|
||||||
let mut current_text = String::new();
|
DiffTag::Equal => {
|
||||||
|
let equal_text: String = op.old_range().map(|i| src_tokens[i]).collect();
|
||||||
for op in diff.ops() {
|
final_line_byte_offset += equal_text.len();
|
||||||
match op.tag() {
|
}
|
||||||
DiffTag::Equal => {
|
DiffTag::Replace => {
|
||||||
for i in op.old_range() {
|
let inserted_text: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
||||||
current_text.push_str(src_tokens[i]);
|
let deleted_text: String = op.old_range().map(|i| src_tokens[i]).collect();
|
||||||
}
|
push_typed_text_candidates(
|
||||||
}
|
&mut candidate_splits,
|
||||||
DiffTag::Replace => {
|
edit_byte_offset,
|
||||||
let ins: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
&tgt_line,
|
||||||
let del: String = op.old_range().map(|i| src_tokens[i]).collect();
|
final_line_byte_offset,
|
||||||
|
&inserted_text,
|
||||||
// For insertion part
|
);
|
||||||
for ch in ins.chars() {
|
push_deleted_text_candidates(
|
||||||
current_text.push(ch);
|
&mut candidate_splits,
|
||||||
let weight = position_weight(¤t_text, current_text.len());
|
edit_byte_offset + inserted_text.len(),
|
||||||
position_weights.push(weight);
|
&deleted_text,
|
||||||
}
|
);
|
||||||
|
edit_byte_offset += inserted_text.len() + deleted_text.len();
|
||||||
// For deletion part (we're "untyping" from source)
|
final_line_byte_offset += inserted_text.len();
|
||||||
for _ in del.chars() {
|
}
|
||||||
// Weight deletions lower as they represent removing text
|
DiffTag::Insert => {
|
||||||
position_weights.push(2);
|
let inserted_text: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
||||||
}
|
push_typed_text_candidates(
|
||||||
}
|
&mut candidate_splits,
|
||||||
DiffTag::Insert => {
|
edit_byte_offset,
|
||||||
let ins: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
&tgt_line,
|
||||||
for ch in ins.chars() {
|
final_line_byte_offset,
|
||||||
current_text.push(ch);
|
&inserted_text,
|
||||||
let weight = position_weight(¤t_text, current_text.len());
|
);
|
||||||
position_weights.push(weight);
|
edit_byte_offset += inserted_text.len();
|
||||||
}
|
final_line_byte_offset += inserted_text.len();
|
||||||
}
|
}
|
||||||
DiffTag::Delete => {
|
DiffTag::Delete => {
|
||||||
let del: String = op.old_range().map(|i| src_tokens[i]).collect();
|
let deleted_text: String = op.old_range().map(|i| src_tokens[i]).collect();
|
||||||
for _ in del.chars() {
|
push_deleted_text_candidates(
|
||||||
// Weight deletions lower
|
&mut candidate_splits,
|
||||||
position_weights.push(2);
|
edit_byte_offset,
|
||||||
}
|
&deleted_text,
|
||||||
}
|
);
|
||||||
|
edit_byte_offset += deleted_text.len();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use weighted selection to choose split index
|
let Some(selected_split) = weighted_select_candidate(&candidate_splits, seed) else {
|
||||||
if position_weights.is_empty() {
|
|
||||||
return no_change;
|
return no_change;
|
||||||
}
|
};
|
||||||
let split_index = weighted_select(&position_weights, seed);
|
let split_byte_offset = selected_split.edit_byte_offset;
|
||||||
|
|
||||||
let mut edit_index = 0usize;
|
let mut edit_index = 0usize;
|
||||||
let mut new_src = String::new();
|
let mut new_src = String::new();
|
||||||
|
|
@ -697,9 +899,9 @@ pub fn imitate_human_edits(
|
||||||
let del: String = op.old_range().map(|i| src_tokens[i]).collect();
|
let del: String = op.old_range().map(|i| src_tokens[i]).collect();
|
||||||
let ins: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
let ins: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
||||||
let repl_len = del.len() + ins.len();
|
let repl_len = del.len() + ins.len();
|
||||||
if edit_index + repl_len >= split_index {
|
if edit_index + repl_len >= split_byte_offset {
|
||||||
// Split within this replace operation
|
// Split within this replace operation
|
||||||
let offset = split_index - edit_index;
|
let offset = split_byte_offset - edit_index;
|
||||||
if offset < ins.len() {
|
if offset < ins.len() {
|
||||||
let safe_offset = floor_char_boundary(&ins, offset);
|
let safe_offset = floor_char_boundary(&ins, offset);
|
||||||
new_src.push_str(&ins[..safe_offset]);
|
new_src.push_str(&ins[..safe_offset]);
|
||||||
|
|
@ -720,8 +922,8 @@ pub fn imitate_human_edits(
|
||||||
}
|
}
|
||||||
DiffTag::Insert => {
|
DiffTag::Insert => {
|
||||||
let repl: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
let repl: String = op.new_range().map(|i| tgt_tokens[i]).collect();
|
||||||
if edit_index + repl.len() >= split_index {
|
if edit_index + repl.len() >= split_byte_offset {
|
||||||
let offset = split_index - edit_index;
|
let offset = split_byte_offset - edit_index;
|
||||||
let safe_offset = floor_char_boundary(&repl, offset);
|
let safe_offset = floor_char_boundary(&repl, offset);
|
||||||
new_src.push_str(&repl[..safe_offset]);
|
new_src.push_str(&repl[..safe_offset]);
|
||||||
split_found = true;
|
split_found = true;
|
||||||
|
|
@ -733,8 +935,8 @@ pub fn imitate_human_edits(
|
||||||
}
|
}
|
||||||
DiffTag::Delete => {
|
DiffTag::Delete => {
|
||||||
let repl: String = op.old_range().map(|i| src_tokens[i]).collect();
|
let repl: String = op.old_range().map(|i| src_tokens[i]).collect();
|
||||||
if edit_index + repl.len() >= split_index {
|
if edit_index + repl.len() >= split_byte_offset {
|
||||||
let offset = split_index - edit_index;
|
let offset = split_byte_offset - edit_index;
|
||||||
let safe_offset = floor_char_boundary(&repl, offset);
|
let safe_offset = floor_char_boundary(&repl, offset);
|
||||||
new_src.push_str(&repl[..safe_offset]);
|
new_src.push_str(&repl[..safe_offset]);
|
||||||
split_found = true;
|
split_found = true;
|
||||||
|
|
@ -754,15 +956,12 @@ pub fn imitate_human_edits(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate cursor position
|
// Calculate cursor position
|
||||||
let cursor = CursorPosition {
|
let line = if is_replacement {
|
||||||
file: tgt_edit_loc.filename.clone(),
|
src_edit_loc.as_ref().unwrap().source_line_number
|
||||||
line: if is_replacement {
|
} else {
|
||||||
src_edit_loc.as_ref().unwrap().source_line_number
|
tgt_edit_loc.target_line_number
|
||||||
} else {
|
|
||||||
tgt_edit_loc.target_line_number
|
|
||||||
},
|
|
||||||
column: new_src.len() + 1,
|
|
||||||
};
|
};
|
||||||
|
let column = new_src.len() + 1;
|
||||||
|
|
||||||
// Add remainder of source if similar enough to target remainder
|
// Add remainder of source if similar enough to target remainder
|
||||||
let remainder_src: String = (last_old_end..src_tokens.len())
|
let remainder_src: String = (last_old_end..src_tokens.len())
|
||||||
|
|
@ -785,6 +984,13 @@ pub fn imitate_human_edits(
|
||||||
return no_change;
|
return no_change;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cursor = CursorPosition {
|
||||||
|
file: tgt_edit_loc.filename.clone(),
|
||||||
|
line,
|
||||||
|
column: column.min(new_src.len()),
|
||||||
|
line_length: new_src.len(),
|
||||||
|
};
|
||||||
|
|
||||||
// Build new source patch with the intermediate line
|
// Build new source patch with the intermediate line
|
||||||
let mut new_src_patch = src_patch;
|
let mut new_src_patch = src_patch;
|
||||||
if is_replacement {
|
if is_replacement {
|
||||||
|
|
@ -860,16 +1066,17 @@ pub fn imitate_human_edits(
|
||||||
fn locate_end_of_last_edit(patch: &Patch) -> Option<CursorPosition> {
|
fn locate_end_of_last_edit(patch: &Patch) -> Option<CursorPosition> {
|
||||||
let loc = locate_edited_line(patch, -1)?;
|
let loc = locate_edited_line(patch, -1)?;
|
||||||
|
|
||||||
let (line, col) = match &loc.patch_line {
|
let (line, column, line_length) = match &loc.patch_line {
|
||||||
PatchLine::Addition(content) => (loc.target_line_number, content.len()),
|
PatchLine::Addition(content) => (loc.target_line_number, content.len(), content.len()),
|
||||||
PatchLine::Deletion(_) => (loc.target_line_number, 1),
|
PatchLine::Deletion(_) => (loc.target_line_number, 1, 1),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(CursorPosition {
|
Some(CursorPosition {
|
||||||
file: loc.filename,
|
file: loc.filename,
|
||||||
line,
|
line,
|
||||||
column: col,
|
column,
|
||||||
|
line_length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -878,7 +1085,7 @@ fn locate_beginning_of_first_edit(patch: &Patch) -> Option<CursorPosition> {
|
||||||
let loc = locate_edited_line(patch, 0)?;
|
let loc = locate_edited_line(patch, 0)?;
|
||||||
|
|
||||||
let hunk = patch.hunks.get(loc.hunk_index)?;
|
let hunk = patch.hunks.get(loc.hunk_index)?;
|
||||||
let column = if loc.line_index_within_hunk > 0 {
|
let line_length = if loc.line_index_within_hunk > 0 {
|
||||||
if let Some(prev_line) = hunk.lines.get(loc.line_index_within_hunk - 1) {
|
if let Some(prev_line) = hunk.lines.get(loc.line_index_within_hunk - 1) {
|
||||||
let content = match prev_line {
|
let content = match prev_line {
|
||||||
PatchLine::Context(s) | PatchLine::Addition(s) | PatchLine::Deletion(s) => s,
|
PatchLine::Context(s) | PatchLine::Addition(s) | PatchLine::Deletion(s) => s,
|
||||||
|
|
@ -893,32 +1100,57 @@ fn locate_beginning_of_first_edit(patch: &Patch) -> Option<CursorPosition> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let line = loc.target_line_number.saturating_sub(1).max(1);
|
let line = loc.target_line_number.saturating_sub(1).max(1);
|
||||||
|
let column = line_length.saturating_sub(1);
|
||||||
|
|
||||||
Some(CursorPosition {
|
Some(CursorPosition {
|
||||||
file: loc.filename,
|
file: loc.filename,
|
||||||
line,
|
line,
|
||||||
column,
|
column,
|
||||||
|
line_length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sample cursor position according to the following rules:
|
/// Sample cursor position according to the following rules:
|
||||||
/// 1. 50% chance of cursor being at the end of the source patch
|
/// 1. 80% chance of cursor being at the end of the source patch
|
||||||
/// 2. 50% chance of cursor being at the beginning of the target patch
|
/// 2. 20% chance of cursor being at the beginning of the target patch
|
||||||
pub fn sample_cursor_position(patch: &Patch, split_commit: &SplitCommit) -> Option<CursorPosition> {
|
/// 3. 20% chance of adding a jitter offset
|
||||||
// Try end of history first
|
pub fn sample_cursor_position(
|
||||||
|
split_commit: &SplitCommit,
|
||||||
|
rng: &mut dyn rand::RngCore,
|
||||||
|
) -> Option<CursorPosition> {
|
||||||
|
// End of history
|
||||||
let src_patch = Patch::parse_unified_diff(&split_commit.source_patch);
|
let src_patch = Patch::parse_unified_diff(&split_commit.source_patch);
|
||||||
if let Some(cursor) = locate_end_of_last_edit(&src_patch) {
|
let src_cursor = locate_end_of_last_edit(&src_patch);
|
||||||
return Some(cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try beginning of target
|
// Beginning of target
|
||||||
let tgt_patch = Patch::parse_unified_diff(&split_commit.target_patch);
|
let tgt_patch = Patch::parse_unified_diff(&split_commit.target_patch);
|
||||||
if let Some(cursor) = locate_beginning_of_first_edit(&tgt_patch) {
|
let tgt_cursor = locate_beginning_of_first_edit(&tgt_patch);
|
||||||
return Some(cursor);
|
|
||||||
|
// Randomly pick a cursor position
|
||||||
|
let prefer_source = rng.random_bool(0.8);
|
||||||
|
let mut cursor = if prefer_source {
|
||||||
|
src_cursor.or(tgt_cursor)
|
||||||
|
} else {
|
||||||
|
tgt_cursor.or(src_cursor)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Possible add jitter
|
||||||
|
let should_jitter = rng.random_bool(0.2);
|
||||||
|
if should_jitter {
|
||||||
|
if let Some(cursor) = cursor.as_mut() {
|
||||||
|
let col_offset = rng.random_range(1..=5);
|
||||||
|
if rng.random_bool(0.5) {
|
||||||
|
cursor.column = cursor
|
||||||
|
.column
|
||||||
|
.saturating_add(col_offset)
|
||||||
|
.min(cursor.line_length);
|
||||||
|
} else {
|
||||||
|
cursor.column = cursor.column.saturating_sub(col_offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: use the original patch
|
cursor
|
||||||
locate_end_of_last_edit(patch)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cursor excerpt from the patches.
|
/// Get cursor excerpt from the patches.
|
||||||
|
|
@ -1147,6 +1379,55 @@ mod tests {
|
||||||
assert_eq!(tgt_patch.stats().added, 1);
|
assert_eq!(tgt_patch.stats().added, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_split_ordered_commit_target_header_continues_current_group() {
|
||||||
|
let commit = r#"////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Update dependency version
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
--- a/go.mod
|
||||||
|
+++ b/go.mod
|
||||||
|
@@ -1,3 +1,3 @@
|
||||||
|
require (
|
||||||
|
- gopkg.in/yaml.v3 v3.0.0 // indirect
|
||||||
|
+ gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
diff --git a/go.sum b/go.sum
|
||||||
|
index f71a068..b8cc3c2 100644
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Update go.sum checksums
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
--- a/go.sum
|
||||||
|
+++ b/go.sum
|
||||||
|
@@ -1,3 +1,5 @@
|
||||||
|
gopkg.in/yaml.v3 v3.0.0 h1:old
|
||||||
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:oldmod
|
||||||
|
+gopkg.in/yaml.v3 v3.0.1 h1:new
|
||||||
|
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:newmod
|
||||||
|
diff --git a/lib/handler.go b/lib/handler.go
|
||||||
|
index 1827a70..d9b3ed1 100644
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fix error wrapping
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
--- a/lib/handler.go
|
||||||
|
+++ b/lib/handler.go
|
||||||
|
@@ -1,3 +1,3 @@
|
||||||
|
- return fmt.Errorf("failed: %s", err)
|
||||||
|
+ return fmt.Errorf("failed: %w", err)
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (_source, target) = split_ordered_commit(commit, 3);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
target.starts_with(
|
||||||
|
"////////////////////////////////////////////////////////////////////////////////\n// Update go.sum checksums\n////////////////////////////////////////////////////////////////////////////////\n"
|
||||||
|
),
|
||||||
|
"target patch should continue with the active group header:\n{target}"
|
||||||
|
);
|
||||||
|
assert!(!target.starts_with(
|
||||||
|
"////////////////////////////////////////////////////////////////////////////////\n// Update dependency version\n////////////////////////////////////////////////////////////////////////////////\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_evaluation_example() {
|
fn test_generate_evaluation_example() {
|
||||||
let commit = r#"commit abc123
|
let commit = r#"commit abc123
|
||||||
|
|
@ -1230,6 +1511,7 @@ Date: Mon Jan 1 00:00:00 2024
|
||||||
file: "src/main.rs".to_string(),
|
file: "src/main.rs".to_string(),
|
||||||
line: 42,
|
line: 42,
|
||||||
column: 10,
|
column: 10,
|
||||||
|
line_length: 80,
|
||||||
};
|
};
|
||||||
assert_eq!(cursor.to_string(), "src/main.rs:42:10");
|
assert_eq!(cursor.to_string(), "src/main.rs:42:10");
|
||||||
}
|
}
|
||||||
|
|
@ -1445,6 +1727,44 @@ index 123..456 789
|
||||||
assert!(!case.edit_history.contains("Date:"));
|
assert!(!case.edit_history.contains("Date:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_service_file_detection() {
|
||||||
|
assert!(is_service_file("package.json"));
|
||||||
|
assert!(is_service_file("frontend/yarn.lock"));
|
||||||
|
assert!(is_service_file("a/src/generated/types.pb.go"));
|
||||||
|
assert!(is_service_file("b/.github/workflows/ci.yml"));
|
||||||
|
assert!(is_service_file("web/node_modules/pkg/index.js"));
|
||||||
|
assert!(is_service_file("dist/app.bundle.js"));
|
||||||
|
|
||||||
|
assert!(!is_service_file("src/main.rs"));
|
||||||
|
assert!(!is_service_file("src/build.rs"));
|
||||||
|
assert!(!is_service_file("Cargo.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_starts_on_service_file() {
|
||||||
|
let commit = r#"--- a/src/lib.rs
|
||||||
|
+++ b/src/lib.rs
|
||||||
|
@@ -1,1 +1,2 @@
|
||||||
|
fn lib() {}
|
||||||
|
+pub fn added() {}
|
||||||
|
--- a/package-lock.json
|
||||||
|
+++ b/package-lock.json
|
||||||
|
@@ -1,1 +1,2 @@
|
||||||
|
{}
|
||||||
|
+{"lockfileVersion": 3}
|
||||||
|
--- a/src/main.rs
|
||||||
|
+++ b/src/main.rs
|
||||||
|
@@ -1,1 +1,2 @@
|
||||||
|
fn main() {}
|
||||||
|
+println!("hello");
|
||||||
|
"#;
|
||||||
|
let patch = Patch::parse_unified_diff(commit);
|
||||||
|
|
||||||
|
assert!(edit_starts_on_service_file(&patch, 1));
|
||||||
|
assert!(!edit_starts_on_service_file(&patch, 2));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_position_weight() {
|
fn test_position_weight() {
|
||||||
// High weight positions (natural pause points)
|
// High weight positions (natural pause points)
|
||||||
|
|
@ -1760,6 +2080,7 @@ index 123..456 789
|
||||||
file: "test.md".to_string(),
|
file: "test.md".to_string(),
|
||||||
line: 1,
|
line: 1,
|
||||||
column: 1, // Byte index 1 is inside '第' (bytes 0..3)
|
column: 1, // Byte index 1 is inside '第' (bytes 0..3)
|
||||||
|
line_length: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
let source_patch = r#"--- a/test.md
|
let source_patch = r#"--- a/test.md
|
||||||
|
|
|
||||||
|
|
@ -1956,6 +1956,7 @@ impl Editor {
|
||||||
|
|
||||||
let mut element = h_flex()
|
let mut element = h_flex()
|
||||||
.items_start()
|
.items_start()
|
||||||
|
.debug_selector(|| "edit_prediction_diff_popover".into())
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.bg(cx.theme().colors().editor_background)
|
.bg(cx.theme().colors().editor_background)
|
||||||
|
|
@ -2022,6 +2023,7 @@ impl Editor {
|
||||||
right: -right_margin,
|
right: -right_margin,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
let popover_right_bound = cmp::min(text_bounds.right(), viewport_bounds.right());
|
||||||
|
|
||||||
let x_after_longest = Pixels::from(
|
let x_after_longest = Pixels::from(
|
||||||
ScrollPixelOffset::from(
|
ScrollPixelOffset::from(
|
||||||
|
|
@ -2031,14 +2033,12 @@ impl Editor {
|
||||||
|
|
||||||
let element_bounds = element.layout_as_root(AvailableSpace::min_size(), window, cx);
|
let element_bounds = element.layout_as_root(AvailableSpace::min_size(), window, cx);
|
||||||
|
|
||||||
// Fully visible if it can be displayed within the window (allow overlapping other
|
|
||||||
// panes). However, this is only allowed if the popover starts within text_bounds.
|
|
||||||
let can_position_to_the_right = x_after_longest < text_bounds.right()
|
let can_position_to_the_right = x_after_longest < text_bounds.right()
|
||||||
&& x_after_longest + element_bounds.width < viewport_bounds.right();
|
&& element_bounds.width <= popover_right_bound - text_bounds.left();
|
||||||
|
|
||||||
let mut origin = if can_position_to_the_right {
|
let mut origin = if can_position_to_the_right {
|
||||||
point(
|
point(
|
||||||
x_after_longest,
|
x_after_longest.min(popover_right_bound - element_bounds.width),
|
||||||
text_bounds.origin.y
|
text_bounds.origin.y
|
||||||
+ Pixels::from(
|
+ Pixels::from(
|
||||||
edit_start.row().as_f64() * ScrollPixelOffset::from(line_height)
|
edit_start.row().as_f64() * ScrollPixelOffset::from(line_height)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ use edit_prediction_types::{
|
||||||
};
|
};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Entity, KeyBinding, KeybindingKeystroke, Keystroke, Modifiers, NoAction, Task, prelude::*,
|
Entity, Focusable, KeyBinding, KeybindingKeystroke, Keystroke, Modifiers, NoAction, Pixels,
|
||||||
|
Task, prelude::*, size,
|
||||||
};
|
};
|
||||||
use indoc::indoc;
|
use indoc::indoc;
|
||||||
use language::EditPredictionsMode;
|
use language::EditPredictionsMode;
|
||||||
|
|
@ -25,13 +26,154 @@ use ui::prelude::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
AcceptEditPrediction, CodeContextMenu, CompletionContext, CompletionProvider, EditPrediction,
|
AcceptEditPrediction, CodeContextMenu, CompletionContext, CompletionProvider, EditPrediction,
|
||||||
EditPredictionKeybindAction, EditPredictionKeybindSurface, MenuEditPredictionsPolicy,
|
EditPredictionKeybindAction, EditPredictionKeybindSurface, MenuEditPredictionsPolicy,
|
||||||
ShowCompletions,
|
MultiBuffer, ShowCompletions,
|
||||||
editor_tests::{init_test, update_test_language_settings},
|
editor_tests::{init_test, update_test_language_settings},
|
||||||
test::{editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext},
|
test::{
|
||||||
|
build_editor, editor_lsp_test_context::EditorLspTestContext,
|
||||||
|
editor_test_context::EditorTestContext,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use rpc::proto::PeerId;
|
use rpc::proto::PeerId;
|
||||||
use workspace::CollaboratorId;
|
use workspace::CollaboratorId;
|
||||||
|
|
||||||
|
struct EditorWithRightOccluders {
|
||||||
|
editor: Entity<crate::Editor>,
|
||||||
|
editor_width: Pixels,
|
||||||
|
right_dock_width: Option<Pixels>,
|
||||||
|
right_sidebar_width: Option<Pixels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for EditorWithRightOccluders {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.size_full()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h_full()
|
||||||
|
.w(self.editor_width)
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(self.editor.clone()),
|
||||||
|
)
|
||||||
|
.when_some(self.right_dock_width, |this, width| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.h_full()
|
||||||
|
.w(width)
|
||||||
|
.flex_shrink_0()
|
||||||
|
.occlude()
|
||||||
|
.debug_selector(|| "right_dock".into()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.right_sidebar_width, |this, width| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.h_full()
|
||||||
|
.w(width)
|
||||||
|
.flex_shrink_0()
|
||||||
|
.occlude()
|
||||||
|
.debug_selector(|| "right_sidebar".into()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_edit_prediction_diff_popover_avoids_right_occluders(
|
||||||
|
cx: &mut gpui::TestAppContext,
|
||||||
|
right_dock_width: Option<Pixels>,
|
||||||
|
right_sidebar_width: Option<Pixels>,
|
||||||
|
) {
|
||||||
|
init_test(cx, |_| {});
|
||||||
|
|
||||||
|
let editor_width = px(700.);
|
||||||
|
let window_width = editor_width
|
||||||
|
+ right_dock_width.unwrap_or_default()
|
||||||
|
+ right_sidebar_width.unwrap_or_default();
|
||||||
|
let buffer = cx.update(|cx| MultiBuffer::build_simple("", cx));
|
||||||
|
let window = cx.add_window(|window, cx| {
|
||||||
|
let editor = cx.new(|cx| build_editor(buffer, window, cx));
|
||||||
|
window.focus(&editor.focus_handle(cx), cx);
|
||||||
|
EditorWithRightOccluders {
|
||||||
|
editor,
|
||||||
|
editor_width,
|
||||||
|
right_dock_width,
|
||||||
|
right_sidebar_width,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let editor = window
|
||||||
|
.read_with(cx, |root, _| root.editor.clone())
|
||||||
|
.expect("test window should contain editor");
|
||||||
|
let mut cx = gpui::VisualTestContext::from_window(*window, cx);
|
||||||
|
cx.simulate_resize(size(window_width, px(500.)));
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let mut cx = EditorTestContext::for_editor_in(editor, &mut cx).await;
|
||||||
|
let provider = cx.new(|_| FakeEditPredictionDelegate::default());
|
||||||
|
assign_editor_completion_provider(provider.clone(), &mut cx);
|
||||||
|
cx.update_editor(|editor, _, _| {
|
||||||
|
editor.set_menu_edit_predictions_policy(MenuEditPredictionsPolicy::Never);
|
||||||
|
});
|
||||||
|
cx.set_state("abcdefghijklmnopqrstuvwxyzabcdefghijklmnˇopqrstuvwxyzabcdef");
|
||||||
|
|
||||||
|
propose_edits(&provider, vec![(40..41, "REPLACEMENT")], &mut cx);
|
||||||
|
cx.update_editor(|editor, window, cx| editor.update_visible_edit_prediction(window, cx));
|
||||||
|
cx.editor(|editor, _, _| {
|
||||||
|
assert!(!editor.edit_prediction_visible_in_cursor_popover(true));
|
||||||
|
assert!(matches!(
|
||||||
|
editor
|
||||||
|
.active_edit_prediction
|
||||||
|
.as_ref()
|
||||||
|
.map(|state| &state.completion),
|
||||||
|
Some(EditPrediction::Edit {
|
||||||
|
display_mode: crate::EditDisplayMode::DiffPopover,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
});
|
||||||
|
cx.cx.update(|window, cx| {
|
||||||
|
window.refresh();
|
||||||
|
let _ = window.draw(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.editor(|editor, _, _| {
|
||||||
|
assert!(
|
||||||
|
editor.last_position_map.is_some(),
|
||||||
|
"editor should have rendered a position map"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let popover_bounds = cx
|
||||||
|
.cx
|
||||||
|
.debug_bounds("edit_prediction_diff_popover")
|
||||||
|
.expect("diff popover should render");
|
||||||
|
|
||||||
|
for selector in ["right_dock", "right_sidebar"] {
|
||||||
|
if let Some(occluder_bounds) = cx.cx.debug_bounds(selector) {
|
||||||
|
assert!(
|
||||||
|
!popover_bounds.intersects(&occluder_bounds),
|
||||||
|
"diff popover {popover_bounds:?} should not overlap {selector} {occluder_bounds:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_edit_prediction_diff_popover_avoids_right_sidebar(cx: &mut gpui::TestAppContext) {
|
||||||
|
assert_edit_prediction_diff_popover_avoids_right_occluders(cx, None, Some(px(300.))).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_edit_prediction_diff_popover_avoids_right_dock(cx: &mut gpui::TestAppContext) {
|
||||||
|
assert_edit_prediction_diff_popover_avoids_right_occluders(cx, Some(px(300.)), None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_edit_prediction_diff_popover_avoids_right_dock_and_sidebar(
|
||||||
|
cx: &mut gpui::TestAppContext,
|
||||||
|
) {
|
||||||
|
assert_edit_prediction_diff_popover_avoids_right_occluders(cx, Some(px(300.)), Some(px(300.)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_edit_prediction_insert(cx: &mut gpui::TestAppContext) {
|
async fn test_edit_prediction_insert(cx: &mut gpui::TestAppContext) {
|
||||||
init_test(cx, |_| {});
|
init_test(cx, |_| {});
|
||||||
|
|
|
||||||
|
|
@ -2650,16 +2650,6 @@ impl Editor {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the project path for the editor's buffer, if any buffer is
|
|
||||||
/// opened in the editor.
|
|
||||||
pub fn project_path(&self, cx: &App) -> Option<ProjectPath> {
|
|
||||||
if let Some(buffer) = self.buffer.read(cx).as_singleton() {
|
|
||||||
buffer.read(cx).project_path(cx)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selection_menu_enabled(&self, cx: &App) -> bool {
|
pub fn selection_menu_enabled(&self, cx: &App) -> bool {
|
||||||
self.show_selection_menu
|
self.show_selection_menu
|
||||||
.unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu)
|
.unwrap_or_else(|| EditorSettings::get_global(cx).toolbar.selections_menu)
|
||||||
|
|
|
||||||
|
|
@ -28013,7 +28013,7 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) {
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap());
|
let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap());
|
||||||
let abs_path = project.read_with(cx, |project, cx| {
|
let abs_path = project.read_with(cx, |project, cx| {
|
||||||
project
|
project
|
||||||
.absolute_path(&project_path, cx)
|
.absolute_path(&project_path, cx)
|
||||||
|
|
@ -28163,7 +28163,8 @@ async fn test_breakpoint_after_save_as_existing_path(cx: &mut TestAppContext) {
|
||||||
editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx);
|
editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
let project_path = first_editor.update(cx, |editor, cx| editor.project_path(cx).unwrap());
|
let project_path =
|
||||||
|
first_editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap());
|
||||||
let abs_path = project.read_with(cx, |project, cx| {
|
let abs_path = project.read_with(cx, |project, cx| {
|
||||||
project
|
project
|
||||||
.absolute_path(&project_path, cx)
|
.absolute_path(&project_path, cx)
|
||||||
|
|
@ -28231,7 +28232,7 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) {
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap());
|
let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap());
|
||||||
let abs_path = project.read_with(cx, |project, cx| {
|
let abs_path = project.read_with(cx, |project, cx| {
|
||||||
project
|
project
|
||||||
.absolute_path(&project_path, cx)
|
.absolute_path(&project_path, cx)
|
||||||
|
|
@ -28402,7 +28403,7 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) {
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap());
|
let project_path = editor.update(cx, |editor, cx| editor.active_project_path(cx).unwrap());
|
||||||
let abs_path = project.read_with(cx, |project, cx| {
|
let abs_path = project.read_with(cx, |project, cx| {
|
||||||
project
|
project
|
||||||
.absolute_path(&project_path, cx)
|
.absolute_path(&project_path, cx)
|
||||||
|
|
@ -28563,9 +28564,9 @@ impl BookmarkTestContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn abs_path(&self) -> Arc<Path> {
|
fn abs_path(&self) -> Arc<Path> {
|
||||||
let project_path = self
|
let project_path = self.editor.read_with(&self.cx, |editor, cx| {
|
||||||
.editor
|
editor.active_project_path(cx).unwrap()
|
||||||
.read_with(&self.cx, |editor, cx| editor.project_path(cx).unwrap());
|
});
|
||||||
self.project.read_with(&self.cx, |project, cx| {
|
self.project.read_with(&self.cx, |project, cx| {
|
||||||
project
|
project
|
||||||
.absolute_path(&project_path, cx)
|
.absolute_path(&project_path, cx)
|
||||||
|
|
|
||||||
|
|
@ -816,6 +816,10 @@ impl Item for Editor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.active_buffer(cx)?.read(cx).project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn can_save_as(&self, cx: &App) -> bool {
|
fn can_save_as(&self, cx: &App) -> bool {
|
||||||
self.buffer.read(cx).is_singleton()
|
self.buffer.read(cx).is_singleton()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1756,6 +1756,10 @@ impl Item for SplittableEditor {
|
||||||
self.rhs_editor.read(cx).buffer_kind(cx)
|
self.rhs_editor.read(cx).buffer_kind(cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<project::ProjectPath> {
|
||||||
|
self.rhs_editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_dirty(&self, cx: &App) -> bool {
|
fn is_dirty(&self, cx: &App) -> bool {
|
||||||
self.rhs_editor.read(cx).is_dirty(cx)
|
self.rhs_editor.read(cx).is_dirty(cx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ use serde_json::json;
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
use util::{path, rel_path::rel_path};
|
use util::{path, rel_path::rel_path};
|
||||||
use workspace::{
|
use workspace::{
|
||||||
AppState, CloseActiveItem, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, open_paths,
|
AppState, CloseActiveItem, Item, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace,
|
||||||
|
open_paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[ctor::ctor]
|
#[ctor::ctor]
|
||||||
|
|
@ -1540,7 +1541,7 @@ async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) {
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
cx.read(|cx| {
|
cx.read(|cx| {
|
||||||
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
||||||
let project_path = active_editor.read(cx).project_path(cx);
|
let project_path = active_editor.read(cx).active_project_path(cx);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
project_path,
|
project_path,
|
||||||
Some(ProjectPath {
|
Some(ProjectPath {
|
||||||
|
|
@ -1618,7 +1619,7 @@ async fn test_create_file_focused_file_does_not_belong_to_available_worktrees(
|
||||||
cx.read(|cx| {
|
cx.read(|cx| {
|
||||||
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
||||||
|
|
||||||
let project_path = active_editor.read(cx).project_path(cx);
|
let project_path = active_editor.read(cx).active_project_path(cx);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
project_path.is_some(),
|
project_path.is_some(),
|
||||||
|
|
@ -1690,7 +1691,7 @@ async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppCon
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
cx.read(|cx| {
|
cx.read(|cx| {
|
||||||
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
|
||||||
let project_path = active_editor.read(cx).project_path(cx);
|
let project_path = active_editor.read(cx).active_project_path(cx);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
project_path,
|
project_path,
|
||||||
Some(ProjectPath {
|
Some(ProjectPath {
|
||||||
|
|
|
||||||
|
|
@ -489,11 +489,11 @@ impl GitRepository for FakeGitRepository {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stash_entries(&self) -> BoxFuture<'_, Result<git::stash::GitStash>> {
|
fn stash_entries(&self) -> BoxFuture<'static, Result<git::stash::GitStash>> {
|
||||||
self.with_state_async(false, |state| Ok(state.stash_entries.clone()))
|
self.with_state_async(false, |state| Ok(state.stash_entries.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
|
fn branches(&self) -> BoxFuture<'_, Result<git::repository::BranchesScanResult>> {
|
||||||
self.with_state_async(false, move |state| {
|
self.with_state_async(false, move |state| {
|
||||||
let current_branch = &state.current_branch_name;
|
let current_branch = &state.current_branch_name;
|
||||||
let mut branches = state
|
let mut branches = state
|
||||||
|
|
@ -518,7 +518,7 @@ impl GitRepository for FakeGitRepository {
|
||||||
// compute snapshot expects these to be sorted by ref_name
|
// compute snapshot expects these to be sorted by ref_name
|
||||||
// because that's what git itself does
|
// because that's what git itself does
|
||||||
branches.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
|
branches.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
|
||||||
Ok(branches)
|
Ok(branches.into())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1127,7 +1127,7 @@ impl GitRepository for FakeGitRepository {
|
||||||
fn diff_stat(
|
fn diff_stat(
|
||||||
&self,
|
&self,
|
||||||
path_prefixes: &[RepoPath],
|
path_prefixes: &[RepoPath],
|
||||||
) -> BoxFuture<'_, Result<git::status::GitDiffStat>> {
|
) -> BoxFuture<'static, Result<git::status::GitDiffStat>> {
|
||||||
fn count_lines(s: &str) -> u32 {
|
fn count_lines(s: &str) -> u32 {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
0
|
0
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ use std::collections::HashSet;
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
use std::process::ExitStatus;
|
use std::process::{ExitStatus, Output};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::{
|
use std::{
|
||||||
cmp::Ordering,
|
cmp::Ordering,
|
||||||
|
|
@ -218,6 +218,21 @@ pub struct Branch {
|
||||||
pub most_recent_commit: Option<CommitSummary>,
|
pub most_recent_commit: Option<CommitSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct BranchesScanResult {
|
||||||
|
pub branches: Vec<Branch>,
|
||||||
|
pub error: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<Branch>> for BranchesScanResult {
|
||||||
|
fn from(branches: Vec<Branch>) -> Self {
|
||||||
|
Self {
|
||||||
|
branches,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Branch {
|
impl Branch {
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
self.ref_name
|
self.ref_name
|
||||||
|
|
@ -792,9 +807,9 @@ pub trait GitRepository: Send + Sync {
|
||||||
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
|
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
|
||||||
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
|
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
|
||||||
|
|
||||||
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
|
fn stash_entries(&self) -> BoxFuture<'static, Result<GitStash>>;
|
||||||
|
|
||||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
|
fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>>;
|
||||||
|
|
||||||
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
|
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
|
||||||
fn create_branch(&self, name: String, base_branch: Option<String>)
|
fn create_branch(&self, name: String, base_branch: Option<String>)
|
||||||
|
|
@ -967,7 +982,7 @@ pub trait GitRepository: Send + Sync {
|
||||||
fn diff_stat(
|
fn diff_stat(
|
||||||
&self,
|
&self,
|
||||||
path_prefixes: &[RepoPath],
|
path_prefixes: &[RepoPath],
|
||||||
) -> BoxFuture<'_, Result<crate::status::GitDiffStat>>;
|
) -> BoxFuture<'static, Result<crate::status::GitDiffStat>>;
|
||||||
|
|
||||||
/// Creates a checkpoint for the repository.
|
/// Creates a checkpoint for the repository.
|
||||||
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
|
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
|
||||||
|
|
@ -1772,7 +1787,7 @@ impl GitRepository for RealGitRepository {
|
||||||
.boxed()
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
|
fn stash_entries(&self) -> BoxFuture<'static, Result<GitStash>> {
|
||||||
let git_binary = self.git_binary_in_worktree();
|
let git_binary = self.git_binary_in_worktree();
|
||||||
self.executor
|
self.executor
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
|
|
@ -1792,7 +1807,7 @@ impl GitRepository for RealGitRepository {
|
||||||
.boxed()
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
|
fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>> {
|
||||||
let git = self.git_binary();
|
let git = self.git_binary();
|
||||||
self.executor
|
self.executor
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
|
|
@ -1817,14 +1832,15 @@ impl GitRepository for RealGitRepository {
|
||||||
];
|
];
|
||||||
let output = git.build_command(&args).output().await?;
|
let output = git.build_command(&args).output().await?;
|
||||||
|
|
||||||
anyhow::ensure!(
|
let error = if output.status.success() {
|
||||||
output.status.success(),
|
None
|
||||||
"Failed to git git branches:\n{}",
|
} else {
|
||||||
String::from_utf8_lossy(&output.stderr)
|
let error = format_branch_scan_error(&output);
|
||||||
);
|
log::warn!("failed to get git branches with commit metadata: {error}");
|
||||||
|
Some(error.into())
|
||||||
|
};
|
||||||
|
|
||||||
let input = String::from_utf8_lossy(&output.stdout);
|
let input = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
let mut branches = parse_branch_input(&input)?;
|
let mut branches = parse_branch_input(&input)?;
|
||||||
if branches.is_empty() {
|
if branches.is_empty() {
|
||||||
let args = vec!["symbolic-ref", "--quiet", "HEAD"];
|
let args = vec!["symbolic-ref", "--quiet", "HEAD"];
|
||||||
|
|
@ -1845,7 +1861,7 @@ impl GitRepository for RealGitRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(branches)
|
Ok(BranchesScanResult { branches, error })
|
||||||
})
|
})
|
||||||
.boxed()
|
.boxed()
|
||||||
}
|
}
|
||||||
|
|
@ -2126,7 +2142,7 @@ impl GitRepository for RealGitRepository {
|
||||||
fn diff_stat(
|
fn diff_stat(
|
||||||
&self,
|
&self,
|
||||||
path_prefixes: &[RepoPath],
|
path_prefixes: &[RepoPath],
|
||||||
) -> BoxFuture<'_, Result<crate::status::GitDiffStat>> {
|
) -> BoxFuture<'static, Result<crate::status::GitDiffStat>> {
|
||||||
let path_prefixes = path_prefixes.to_vec();
|
let path_prefixes = path_prefixes.to_vec();
|
||||||
let git_binary = self.git_binary_in_worktree();
|
let git_binary = self.git_binary_in_worktree();
|
||||||
|
|
||||||
|
|
@ -3647,6 +3663,17 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
|
||||||
Ok(branches)
|
Ok(branches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_branch_scan_error(output: &Output) -> String {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr)
|
||||||
|
.trim()
|
||||||
|
.replace('\n', " ");
|
||||||
|
if stderr.is_empty() {
|
||||||
|
format!("git for-each-ref exited with {}", output.status)
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
|
fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
|
||||||
if upstream_track.is_empty() {
|
if upstream_track.is_empty() {
|
||||||
return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
|
||||||
|
|
@ -3926,7 +3953,7 @@ mod tests {
|
||||||
let checkpoint = repo.checkpoint().await.unwrap();
|
let checkpoint = repo.checkpoint().await.unwrap();
|
||||||
|
|
||||||
// Ensure the user can't see any branches after creating a checkpoint.
|
// Ensure the user can't see any branches after creating a checkpoint.
|
||||||
assert_eq!(repo.branches().await.unwrap().len(), 1);
|
assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
|
||||||
|
|
||||||
smol::fs::write(&file_path, "modified after checkpoint")
|
smol::fs::write(&file_path, "modified after checkpoint")
|
||||||
.await
|
.await
|
||||||
|
|
@ -3996,7 +4023,7 @@ mod tests {
|
||||||
let checkpoint_sha = repo.checkpoint().await.unwrap();
|
let checkpoint_sha = repo.checkpoint().await.unwrap();
|
||||||
|
|
||||||
// Ensure the user can't see any branches after creating a checkpoint.
|
// Ensure the user can't see any branches after creating a checkpoint.
|
||||||
assert_eq!(repo.branches().await.unwrap().len(), 1);
|
assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
|
||||||
|
|
||||||
smol::fs::write(repo_dir.path().join("foo"), "bar")
|
smol::fs::write(repo_dir.path().join("foo"), "bar")
|
||||||
.await
|
.await
|
||||||
|
|
@ -4020,6 +4047,64 @@ mod tests {
|
||||||
// );
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_branches_return_head_when_commit_metadata_cannot_be_read(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) {
|
||||||
|
disable_git_global_config();
|
||||||
|
|
||||||
|
cx.executor().allow_parking();
|
||||||
|
|
||||||
|
let repo_dir = tempfile::tempdir().unwrap();
|
||||||
|
git2::Repository::init(repo_dir.path()).unwrap();
|
||||||
|
let repo = RealGitRepository::new(
|
||||||
|
&repo_dir.path().join(".git"),
|
||||||
|
None,
|
||||||
|
Some("git".into()),
|
||||||
|
cx.executor(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.commit(
|
||||||
|
"Initial commit".into(),
|
||||||
|
None,
|
||||||
|
CommitOptions::default(),
|
||||||
|
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||||
|
Arc::new(checkpoint_author_envs()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
smol::fs::write(
|
||||||
|
repo_dir.path().join(".git").join("refs/heads/broken"),
|
||||||
|
"0a103ede22f159c792dc6405e0c8304d9bd4dc29\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let branches_scan = repo.branches().await.unwrap();
|
||||||
|
assert!(branches_scan.error.is_some());
|
||||||
|
let head_branch = branches_scan
|
||||||
|
.branches
|
||||||
|
.iter()
|
||||||
|
.find(|branch| branch.is_head)
|
||||||
|
.expect("branch list should include HEAD");
|
||||||
|
assert!(head_branch.ref_name.starts_with("refs/heads/"));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
branches_scan
|
||||||
|
.branches
|
||||||
|
.iter()
|
||||||
|
.all(|branch| branch.ref_name.as_ref() != "refs/heads/broken")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_compare_checkpoints(cx: &mut TestAppContext) {
|
async fn test_compare_checkpoints(cx: &mut TestAppContext) {
|
||||||
disable_git_global_config();
|
disable_git_global_config();
|
||||||
|
|
|
||||||
|
|
@ -586,7 +586,7 @@ pub struct DiffStat {
|
||||||
pub deleted: u32,
|
pub deleted: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct GitDiffStat {
|
pub struct GitDiffStat {
|
||||||
pub entries: Arc<[(RepoPath, DiffStat)]>,
|
pub entries: Arc<[(RepoPath, DiffStat)]>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ use project::project_settings::ProjectSettings;
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use ui::{Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
use ui::{
|
||||||
|
Banner, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Severity, Tooltip,
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
use ui_input::ErasedEditor;
|
use ui_input::ErasedEditor;
|
||||||
use util::ResultExt;
|
use util::ResultExt;
|
||||||
use workspace::notifications::DetachAndPromptErr;
|
use workspace::notifications::DetachAndPromptErr;
|
||||||
|
|
@ -233,6 +236,9 @@ impl BranchList {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let branch_list_error = repository
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|repo| repo.read(cx).branch_list_error.clone());
|
||||||
|
|
||||||
let default_branch_request = repository.clone().map(|repository| {
|
let default_branch_request = repository.clone().map(|repository| {
|
||||||
repository.update(cx, |repository, _| repository.default_branch(false))
|
repository.update(cx, |repository, _| repository.default_branch(false))
|
||||||
|
|
@ -246,6 +252,7 @@ impl BranchList {
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
delegate.all_branches = all_branches;
|
delegate.all_branches = all_branches;
|
||||||
|
delegate.branch_list_error = branch_list_error;
|
||||||
|
|
||||||
let picker = cx.new(|cx| {
|
let picker = cx.new(|cx| {
|
||||||
Picker::uniform_list(delegate, window, cx)
|
Picker::uniform_list(delegate, window, cx)
|
||||||
|
|
@ -267,7 +274,9 @@ impl BranchList {
|
||||||
window,
|
window,
|
||||||
move |this, repo, event, window, cx| {
|
move |this, repo, event, window, cx| {
|
||||||
if matches!(event, RepositoryEvent::BranchListChanged) {
|
if matches!(event, RepositoryEvent::BranchListChanged) {
|
||||||
let branch_list = repo.read(cx).branch_list.clone();
|
let snapshot = repo.read(cx);
|
||||||
|
let branch_list = snapshot.branch_list.clone();
|
||||||
|
let branch_list_error = snapshot.branch_list_error.clone();
|
||||||
this.picker.update(cx, |picker, cx| {
|
this.picker.update(cx, |picker, cx| {
|
||||||
picker.delegate.restore_selected_branch = picker
|
picker.delegate.restore_selected_branch = picker
|
||||||
.delegate
|
.delegate
|
||||||
|
|
@ -278,6 +287,7 @@ impl BranchList {
|
||||||
&branch_list,
|
&branch_list,
|
||||||
picker.delegate.branch_selection_behavior.selected_branch(),
|
picker.delegate.branch_selection_behavior.selected_branch(),
|
||||||
);
|
);
|
||||||
|
picker.delegate.branch_list_error = branch_list_error;
|
||||||
picker.refresh(window, cx);
|
picker.refresh(window, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -497,6 +507,7 @@ pub struct BranchListDelegate {
|
||||||
workspace: WeakEntity<Workspace>,
|
workspace: WeakEntity<Workspace>,
|
||||||
matches: Vec<Entry>,
|
matches: Vec<Entry>,
|
||||||
all_branches: Vec<Branch>,
|
all_branches: Vec<Branch>,
|
||||||
|
branch_list_error: Option<SharedString>,
|
||||||
default_branch: Option<SharedString>,
|
default_branch: Option<SharedString>,
|
||||||
repo: Option<Entity<Repository>>,
|
repo: Option<Entity<Repository>>,
|
||||||
style: BranchListStyle,
|
style: BranchListStyle,
|
||||||
|
|
@ -815,6 +826,7 @@ impl BranchListDelegate {
|
||||||
repo,
|
repo,
|
||||||
style,
|
style,
|
||||||
all_branches: Vec::new(),
|
all_branches: Vec::new(),
|
||||||
|
branch_list_error: None,
|
||||||
default_branch: None,
|
default_branch: None,
|
||||||
selected_index: 0,
|
selected_index: 0,
|
||||||
last_query: Default::default(),
|
last_query: Default::default(),
|
||||||
|
|
@ -1046,6 +1058,23 @@ impl PickerDelegate for BranchListDelegate {
|
||||||
self.editor_position() == PickerEditorPosition::End,
|
self.editor_position() == PickerEditorPosition::End,
|
||||||
|this| this.child(Divider::horizontal()),
|
|this| this.child(Divider::horizontal()),
|
||||||
)
|
)
|
||||||
|
.when_some(self.branch_list_error.clone(), |this, error| {
|
||||||
|
let message = format!("Some branches could not be loaded: {error}");
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.id("branch-list-error")
|
||||||
|
.p_1p5()
|
||||||
|
.child(
|
||||||
|
Banner::new().severity(Severity::Warning).child(
|
||||||
|
Label::new(message.clone())
|
||||||
|
.size(LabelSize::Small)
|
||||||
|
.single_line()
|
||||||
|
.truncate(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.tooltip(Tooltip::text(message)),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
|
@ -2191,7 +2220,8 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.branches;
|
||||||
let repo_branches = repo_branches
|
let repo_branches = repo_branches
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.name())
|
.map(|b| b.name())
|
||||||
|
|
@ -2276,7 +2306,8 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.branches;
|
||||||
assert!(
|
assert!(
|
||||||
repo_branches
|
repo_branches
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -2355,7 +2386,8 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.branches;
|
||||||
assert!(
|
assert!(
|
||||||
repo_branches
|
repo_branches
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -2436,7 +2468,8 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.branches;
|
||||||
assert!(
|
assert!(
|
||||||
repo_branches
|
repo_branches
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -2514,7 +2547,8 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.branches;
|
||||||
let repo_branches = repo_branches
|
let repo_branches = repo_branches
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.name())
|
.map(|b| b.name())
|
||||||
|
|
@ -2698,7 +2732,8 @@ mod tests {
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.branches;
|
||||||
|
|
||||||
let new_branch = branches
|
let new_branch = branches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use buffer_diff::BufferDiff;
|
||||||
use collections::HashMap;
|
use collections::HashMap;
|
||||||
use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
|
use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
|
||||||
use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
|
use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
|
||||||
|
use futures_lite::future::yield_now;
|
||||||
use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
|
use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
|
||||||
use git::status::{FileStatus, StatusCode, TrackedStatus};
|
use git::status::{FileStatus, StatusCode, TrackedStatus};
|
||||||
use git::{
|
use git::{
|
||||||
|
|
@ -19,7 +20,7 @@ use language::{
|
||||||
Point, ReplicaId, Rope, TextBuffer,
|
Point, ReplicaId, Rope, TextBuffer,
|
||||||
};
|
};
|
||||||
use multi_buffer::PathKey;
|
use multi_buffer::PathKey;
|
||||||
use project::{Project, WorktreeId, git_store::Repository};
|
use project::{Project, ProjectPath, WorktreeId, git_store::Repository};
|
||||||
use std::{
|
use std::{
|
||||||
any::{Any, TypeId},
|
any::{Any, TypeId},
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
|
|
@ -392,38 +393,58 @@ impl CommitView {
|
||||||
Some(build_buffer_diff(old_text, &buffer, &language_registry, cx).await?)
|
Some(build_buffer_diff(old_text, &buffer, &language_registry, cx).await?)
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
let (excerpt_ranges, path) = cx.update(|cx| {
|
||||||
this.multibuffer.update(cx, |multibuffer, cx| {
|
let snapshot = buffer.read(cx).snapshot();
|
||||||
let snapshot = buffer.read(cx).snapshot();
|
let path = PathKey::with_sort_prefix(
|
||||||
let path = snapshot.file().unwrap().path().clone();
|
FILE_NAMESPACE_SORT_PREFIX,
|
||||||
let excerpt_ranges = if is_binary {
|
snapshot.file().unwrap().path().clone(),
|
||||||
|
);
|
||||||
|
let ranges = if is_binary {
|
||||||
|
vec![language::Point::zero()..snapshot.max_point()]
|
||||||
|
} else if let Some(buffer_diff) = &buffer_diff {
|
||||||
|
let diff_snapshot = buffer_diff.read(cx).snapshot(cx);
|
||||||
|
let mut hunks = diff_snapshot.hunks(&snapshot).peekable();
|
||||||
|
if hunks.peek().is_none() {
|
||||||
vec![language::Point::zero()..snapshot.max_point()]
|
vec![language::Point::zero()..snapshot.max_point()]
|
||||||
} else if let Some(buffer_diff) = &buffer_diff {
|
|
||||||
let diff_snapshot = buffer_diff.read(cx).snapshot(cx);
|
|
||||||
let mut hunks = diff_snapshot.hunks(&snapshot).peekable();
|
|
||||||
if hunks.peek().is_none() {
|
|
||||||
vec![language::Point::zero()..snapshot.max_point()]
|
|
||||||
} else {
|
|
||||||
hunks
|
|
||||||
.map(|hunk| hunk.buffer_range.to_point(&snapshot))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
vec![language::Point::zero()..snapshot.max_point()]
|
hunks
|
||||||
};
|
.map(|hunk| hunk.buffer_range.to_point(&snapshot))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
let _is_newly_added = multibuffer.set_excerpts_for_path(
|
|
||||||
PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
|
|
||||||
buffer,
|
|
||||||
excerpt_ranges,
|
|
||||||
multibuffer_context_lines(cx),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
if let Some(buffer_diff) = buffer_diff {
|
|
||||||
multibuffer.add_diff(buffer_diff, cx);
|
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
})?;
|
vec![language::Point::zero()..snapshot.max_point()]
|
||||||
|
};
|
||||||
|
(ranges, path)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch the insertion of excerpts and yield between batches, to avoid blocking the main thread when a single file has many hunks.
|
||||||
|
const EXCERPT_BATCH_SIZE: usize = 10;
|
||||||
|
let total = excerpt_ranges.len();
|
||||||
|
let mut batch_end = 0;
|
||||||
|
while batch_end < total {
|
||||||
|
let is_first_batch = batch_end == 0;
|
||||||
|
batch_end = (batch_end + EXCERPT_BATCH_SIZE).min(total);
|
||||||
|
let ranges = excerpt_ranges[..batch_end].to_vec();
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.multibuffer.update(cx, |multibuffer, cx| {
|
||||||
|
multibuffer.set_excerpts_for_path(
|
||||||
|
path.clone(),
|
||||||
|
buffer.clone(),
|
||||||
|
ranges,
|
||||||
|
multibuffer_context_lines(cx),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
if is_first_batch {
|
||||||
|
if let Some(buffer_diff) = buffer_diff.clone() {
|
||||||
|
multibuffer.add_diff(buffer_diff, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})?;
|
||||||
|
if batch_end < total {
|
||||||
|
yield_now().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
|
|
@ -997,6 +1018,10 @@ impl Item for CommitView {
|
||||||
self.editor.for_each_project_item(cx, f)
|
self.editor.for_each_project_item(cx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(
|
fn set_nav_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
nav_history: ItemNavHistory,
|
nav_history: ItemNavHistory,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use gpui::{
|
||||||
Focusable, Font, IntoElement, Render, Task, WeakEntity, Window,
|
Focusable, Font, IntoElement, Render, Task, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
use language::{Buffer, HighlightedText, LanguageRegistry};
|
use language::{Buffer, HighlightedText, LanguageRegistry};
|
||||||
use project::Project;
|
use project::{Project, ProjectPath};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
use std::{
|
use std::{
|
||||||
any::{Any, TypeId},
|
any::{Any, TypeId},
|
||||||
|
|
@ -303,6 +303,10 @@ impl Item for FileDiffView {
|
||||||
self.editor.for_each_project_item(cx, f)
|
self.editor.for_each_project_item(cx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(
|
fn set_nav_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
nav_history: ItemNavHistory,
|
nav_history: ItemNavHistory,
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ use util::paths::PathStyle;
|
||||||
use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
|
use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
|
||||||
use workspace::SERIALIZATION_THROTTLE_TIME;
|
use workspace::SERIALIZATION_THROTTLE_TIME;
|
||||||
use workspace::{
|
use workspace::{
|
||||||
Workspace,
|
Item, Workspace,
|
||||||
dock::{DockPosition, Panel, PanelEvent},
|
dock::{DockPosition, Panel, PanelEvent},
|
||||||
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
|
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
|
||||||
};
|
};
|
||||||
|
|
@ -1362,7 +1362,7 @@ impl GitPanel {
|
||||||
let git_repo = self.active_repository.as_ref()?;
|
let git_repo = self.active_repository.as_ref()?;
|
||||||
|
|
||||||
if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
|
if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
|
||||||
&& let Some(project_path) = project_diff.read(cx).active_path(cx)
|
&& let Some(project_path) = project_diff.read(cx).active_project_path(cx)
|
||||||
&& Some(&entry.repo_path)
|
&& Some(&entry.repo_path)
|
||||||
== git_repo
|
== git_repo
|
||||||
.read(cx)
|
.read(cx)
|
||||||
|
|
@ -6988,9 +6988,6 @@ impl RenderOnce for PanelRepoFooter {
|
||||||
.map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
|
.map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
const MAX_BRANCH_LEN: usize = 16;
|
|
||||||
const MAX_REPO_LEN: usize = 16;
|
|
||||||
const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
|
|
||||||
const MAX_SHORT_SHA_LEN: usize = 8;
|
const MAX_SHORT_SHA_LEN: usize = 8;
|
||||||
let branch_name = self
|
let branch_name = self
|
||||||
.branch
|
.branch
|
||||||
|
|
@ -7010,36 +7007,6 @@ impl RenderOnce for PanelRepoFooter {
|
||||||
|
|
||||||
let active_repo_name = self.active_repository.clone();
|
let active_repo_name = self.active_repository.clone();
|
||||||
|
|
||||||
let branch_actual_len = branch_name.len();
|
|
||||||
let repo_actual_len = active_repo_name.len();
|
|
||||||
|
|
||||||
// ideally, show the whole branch and repo names but
|
|
||||||
// when we can't, use a budget to allocate space between the two
|
|
||||||
let (repo_display_len, branch_display_len) =
|
|
||||||
if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
|
|
||||||
(repo_actual_len, branch_actual_len)
|
|
||||||
} else if branch_actual_len <= MAX_BRANCH_LEN {
|
|
||||||
let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
|
|
||||||
(repo_space, branch_actual_len)
|
|
||||||
} else if repo_actual_len <= MAX_REPO_LEN {
|
|
||||||
let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
|
|
||||||
(repo_actual_len, branch_space)
|
|
||||||
} else {
|
|
||||||
(MAX_REPO_LEN, MAX_BRANCH_LEN)
|
|
||||||
};
|
|
||||||
|
|
||||||
let truncated_repo_name = if repo_actual_len <= repo_display_len {
|
|
||||||
active_repo_name.to_string()
|
|
||||||
} else {
|
|
||||||
util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
|
|
||||||
};
|
|
||||||
|
|
||||||
let truncated_branch_name = if branch_actual_len <= branch_display_len {
|
|
||||||
branch_name
|
|
||||||
} else {
|
|
||||||
util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
|
|
||||||
};
|
|
||||||
|
|
||||||
let repo_selector = PopoverMenu::new("repository-switcher")
|
let repo_selector = PopoverMenu::new("repository-switcher")
|
||||||
.menu({
|
.menu({
|
||||||
let project = project;
|
let project = project;
|
||||||
|
|
@ -7049,7 +7016,7 @@ impl RenderOnce for PanelRepoFooter {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.trigger_with_tooltip(
|
.trigger_with_tooltip(
|
||||||
Button::new("repo-selector", truncated_repo_name)
|
Button::new("repo-selector", active_repo_name)
|
||||||
.size(ButtonSize::None)
|
.size(ButtonSize::None)
|
||||||
.label_size(LabelSize::Small)
|
.label_size(LabelSize::Small)
|
||||||
.truncate(true),
|
.truncate(true),
|
||||||
|
|
@ -7068,7 +7035,7 @@ impl RenderOnce for PanelRepoFooter {
|
||||||
})
|
})
|
||||||
.into_any_element();
|
.into_any_element();
|
||||||
|
|
||||||
let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
|
let branch_selector_button = Button::new("branch-selector", branch_name)
|
||||||
.size(ButtonSize::None)
|
.size(ButtonSize::None)
|
||||||
.label_size(LabelSize::Small)
|
.label_size(LabelSize::Small)
|
||||||
.truncate(true)
|
.truncate(true)
|
||||||
|
|
@ -7111,15 +7078,16 @@ impl RenderOnce for PanelRepoFooter {
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
.when(!single_repo, |this| {
|
.when(!single_repo, |this| {
|
||||||
this.child(repo_selector).when(show_separator, |this| {
|
this.child(div().child(repo_selector).min_w_0()).when(
|
||||||
this.child(
|
show_separator,
|
||||||
Label::new("/").size(LabelSize::Small).color(Color::Custom(
|
|this| {
|
||||||
cx.theme().colors().text_muted.opacity(0.4),
|
this.child(Label::new("/").size(LabelSize::Small).color(
|
||||||
)),
|
Color::Custom(cx.theme().colors().text_muted.opacity(0.4)),
|
||||||
)
|
))
|
||||||
})
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.child(branch_selector),
|
.child(div().child(branch_selector).min_w_0()),
|
||||||
)
|
)
|
||||||
.children(if let Some(git_panel) = self.git_panel {
|
.children(if let Some(git_panel) = self.git_panel {
|
||||||
git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
|
git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
|
||||||
|
|
@ -8412,8 +8380,8 @@ mod tests {
|
||||||
.item_of_type::<ProjectDiff>(cx)
|
.item_of_type::<ProjectDiff>(cx)
|
||||||
.expect("ProjectDiff should exist")
|
.expect("ProjectDiff should exist")
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.active_path(cx)
|
.active_project_path(cx)
|
||||||
.expect("active_path should exist");
|
.expect("active_project_path should exist");
|
||||||
|
|
||||||
assert_eq!(active_path.path, rel_path("untracked").into_arc());
|
assert_eq!(active_path.path, rel_path("untracked").into_arc());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use gpui::{
|
||||||
};
|
};
|
||||||
use language::{Buffer, Capability, HighlightedText, OffsetRangeExt};
|
use language::{Buffer, Capability, HighlightedText, OffsetRangeExt};
|
||||||
use multi_buffer::PathKey;
|
use multi_buffer::PathKey;
|
||||||
use project::Project;
|
use project::{Project, ProjectPath};
|
||||||
use std::{
|
use std::{
|
||||||
any::{Any, TypeId},
|
any::{Any, TypeId},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
|
@ -313,6 +313,10 @@ impl Item for MultiDiffView {
|
||||||
Some(Box::new(self.editor.clone()))
|
Some(Box::new(self.editor.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(
|
fn set_nav_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
nav_history: ItemNavHistory,
|
nav_history: ItemNavHistory,
|
||||||
|
|
|
||||||
|
|
@ -544,21 +544,6 @@ impl ProjectDiff {
|
||||||
self.move_to_path(path_key, window, cx)
|
self.move_to_path(path_key, window, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
|
|
||||||
let editor = self.editor.read(cx).focused_editor().read(cx);
|
|
||||||
let multibuffer = editor.buffer().read(cx);
|
|
||||||
let position = editor.selections.newest_anchor().head();
|
|
||||||
let snapshot = multibuffer.snapshot(cx);
|
|
||||||
let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
|
|
||||||
let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
|
|
||||||
|
|
||||||
let file = buffer.read(cx).file()?;
|
|
||||||
Some(ProjectPath {
|
|
||||||
worktree_id: file.worktree_id(cx),
|
|
||||||
path: file.path().clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.editor.update(cx, |editor, cx| {
|
self.editor.update(cx, |editor, cx| {
|
||||||
editor.rhs_editor().update(cx, |editor, cx| {
|
editor.rhs_editor().update(cx, |editor, cx| {
|
||||||
|
|
@ -675,7 +660,7 @@ impl ProjectDiff {
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
EditorEvent::SelectionsChanged { local: true } => {
|
EditorEvent::SelectionsChanged { local: true } => {
|
||||||
let Some(project_path) = self.active_path(cx) else {
|
let Some(project_path) = self.active_project_path(cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
self.workspace
|
self.workspace
|
||||||
|
|
@ -1048,6 +1033,21 @@ impl Item for ProjectDiff {
|
||||||
.for_each_project_item(cx, f)
|
.for_each_project_item(cx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
let editor = self.editor.read(cx).focused_editor().read(cx);
|
||||||
|
let multibuffer = editor.buffer().read(cx);
|
||||||
|
let position = editor.selections.newest_anchor().head();
|
||||||
|
let snapshot = multibuffer.snapshot(cx);
|
||||||
|
let (text_anchor, _) = snapshot.anchor_to_buffer_anchor(position)?;
|
||||||
|
let buffer = multibuffer.buffer(text_anchor.buffer_id)?;
|
||||||
|
|
||||||
|
let file = buffer.read(cx).file()?;
|
||||||
|
Some(ProjectPath {
|
||||||
|
worktree_id: file.worktree_id(cx),
|
||||||
|
path: file.path().clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(
|
fn set_nav_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
nav_history: ItemNavHistory,
|
nav_history: ItemNavHistory,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use gpui::{
|
||||||
Focusable, IntoElement, Render, Task, Window,
|
Focusable, IntoElement, Render, Task, Window,
|
||||||
};
|
};
|
||||||
use language::{self, Buffer, OffsetRangeExt, Point};
|
use language::{self, Buffer, OffsetRangeExt, Point};
|
||||||
use project::Project;
|
use project::{Project, ProjectPath};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
use std::{
|
use std::{
|
||||||
any::{Any, TypeId},
|
any::{Any, TypeId},
|
||||||
|
|
@ -377,6 +377,10 @@ impl Item for TextDiffView {
|
||||||
self.diff_editor.read(cx).for_each_project_item(cx, f)
|
self.diff_editor.read(cx).for_each_project_item(cx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.diff_editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(
|
fn set_nav_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
nav_history: ItemNavHistory,
|
nav_history: ItemNavHistory,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ gpui.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytemuck = "1"
|
bytemuck = "1"
|
||||||
collections.workspace = true
|
collections.workspace = true
|
||||||
cosmic-text = "0.17.0"
|
cosmic-text = "0.19.0"
|
||||||
etagere = "0.2"
|
etagere = "0.2"
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
@ -51,4 +51,4 @@ criterion.workspace = true
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "layout_line"
|
name = "layout_line"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use anyhow::{Context as _, Ok, Result};
|
use anyhow::{Context as _, Ok, Result};
|
||||||
use collections::HashMap;
|
use collections::HashMap;
|
||||||
use cosmic_text::{
|
use cosmic_text::{
|
||||||
Attrs, AttrsList, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures,
|
Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont,
|
||||||
FontSystem, ShapeBuffer, ShapeLine,
|
FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine,
|
||||||
};
|
};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId,
|
Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId,
|
||||||
|
|
@ -544,6 +544,7 @@ impl CosmicTextSystemState {
|
||||||
f32::from(font_size),
|
f32::from(font_size),
|
||||||
None, // We do our own wrapping
|
None, // We do our own wrapping
|
||||||
cosmic_text::Wrap::None,
|
cosmic_text::Wrap::None,
|
||||||
|
Ellipsize::None,
|
||||||
None,
|
None,
|
||||||
&mut layout_lines,
|
&mut layout_lines,
|
||||||
None,
|
None,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
((inline) @injection.content
|
((inline) @injection.content
|
||||||
(#set! injection.language "markdown-inline"))
|
(#set! injection.language "markdown-inline"))
|
||||||
|
|
||||||
|
((pipe_table_cell) @injection.content
|
||||||
|
(#set! injection.language "markdown-inline"))
|
||||||
|
|
||||||
((html_block) @injection.content
|
((html_block) @injection.content
|
||||||
(#set! injection.language "html"))
|
(#set! injection.language "html"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,28 @@ impl Markdown {
|
||||||
&self.source
|
&self.source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn first_code_block_language(&self) -> Option<Arc<Language>> {
|
||||||
|
self.parsed_markdown.events.iter().find_map(|(_, event)| {
|
||||||
|
let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, .. }) = event else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
CodeBlockKind::FencedLang(language) => self
|
||||||
|
.parsed_markdown
|
||||||
|
.languages_by_name
|
||||||
|
.get(language)
|
||||||
|
.cloned(),
|
||||||
|
CodeBlockKind::FencedSrc(path_range) => self
|
||||||
|
.parsed_markdown
|
||||||
|
.languages_by_path
|
||||||
|
.get(&path_range.path)
|
||||||
|
.cloned(),
|
||||||
|
CodeBlockKind::Fenced | CodeBlockKind::Indented => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn append(&mut self, text: &str, cx: &mut Context<Self>) {
|
pub fn append(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||||
self.source = SharedString::new(self.source.to_string() + text);
|
self.source = SharedString::new(self.source.to_string() + text);
|
||||||
self.parse(cx);
|
self.parse(cx);
|
||||||
|
|
@ -1191,7 +1213,10 @@ impl MarkdownElement {
|
||||||
range: Range<usize>,
|
range: Range<usize>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) {
|
) {
|
||||||
let link_url = if builder.code_block_stack.is_empty() && builder.link_depth == 0 {
|
let link_url = if builder.code_block_stack.is_empty()
|
||||||
|
&& builder.link_depth == 0
|
||||||
|
&& !self.style.prevent_mouse_interaction
|
||||||
|
{
|
||||||
self.code_span_link
|
self.code_span_link
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|callback| callback(text, cx))
|
.and_then(|callback| callback(text, cx))
|
||||||
|
|
@ -1531,18 +1556,28 @@ impl MarkdownElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (range, mode) = match event.click_count {
|
let (range, mode, reversed) = match event.click_count {
|
||||||
|
1 if event.modifiers.shift => {
|
||||||
|
let tail = markdown.selection.tail();
|
||||||
|
let reversed = source_index < tail;
|
||||||
|
let range = if reversed {
|
||||||
|
source_index..tail
|
||||||
|
} else {
|
||||||
|
tail..source_index
|
||||||
|
};
|
||||||
|
(range, SelectMode::Character, reversed)
|
||||||
|
}
|
||||||
1 => {
|
1 => {
|
||||||
let range = source_index..source_index;
|
let range = source_index..source_index;
|
||||||
(range, SelectMode::Character)
|
(range, SelectMode::Character, false)
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
let range = rendered_text.surrounding_word_range(source_index);
|
let range = rendered_text.surrounding_word_range(source_index);
|
||||||
(range.clone(), SelectMode::Word(range))
|
(range.clone(), SelectMode::Word(range), false)
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
let range = rendered_text.surrounding_line_range(source_index);
|
let range = rendered_text.surrounding_line_range(source_index);
|
||||||
(range.clone(), SelectMode::Line(range))
|
(range.clone(), SelectMode::Line(range), false)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let range = 0..rendered_text
|
let range = 0..rendered_text
|
||||||
|
|
@ -1550,13 +1585,13 @@ impl MarkdownElement {
|
||||||
.last()
|
.last()
|
||||||
.map(|line| line.source_end)
|
.map(|line| line.source_end)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
(range, SelectMode::All)
|
(range, SelectMode::All, false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
markdown.selection = Selection {
|
markdown.selection = Selection {
|
||||||
start: range.start,
|
start: range.start,
|
||||||
end: range.end,
|
end: range.end,
|
||||||
reversed: false,
|
reversed,
|
||||||
pending: true,
|
pending: true,
|
||||||
mode,
|
mode,
|
||||||
};
|
};
|
||||||
|
|
@ -3468,7 +3503,10 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gpui::{TestAppContext, size};
|
use gpui::{TestAppContext, size};
|
||||||
use language::{Language, LanguageConfig, LanguageMatcher};
|
use language::{Language, LanguageConfig, LanguageMatcher};
|
||||||
use std::sync::Arc;
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
fn ensure_theme_initialized(cx: &mut TestAppContext) {
|
fn ensure_theme_initialized(cx: &mut TestAppContext) {
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
|
|
@ -3540,6 +3578,15 @@ mod tests {
|
||||||
markdown: &str,
|
markdown: &str,
|
||||||
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
|
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
|
||||||
cx: &mut TestAppContext,
|
cx: &mut TestAppContext,
|
||||||
|
) -> RenderedText {
|
||||||
|
render_markdown_with_code_span_link_style(markdown, MarkdownStyle::default(), callback, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown_with_code_span_link_style(
|
||||||
|
markdown: &str,
|
||||||
|
style: MarkdownStyle,
|
||||||
|
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
|
||||||
|
cx: &mut TestAppContext,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
struct TestWindow;
|
struct TestWindow;
|
||||||
|
|
||||||
|
|
@ -3558,7 +3605,7 @@ mod tests {
|
||||||
Default::default(),
|
Default::default(),
|
||||||
size(px(600.0), px(600.0)),
|
size(px(600.0), px(600.0)),
|
||||||
|_window, _cx| {
|
|_window, _cx| {
|
||||||
MarkdownElement::new(markdown, MarkdownStyle::default())
|
MarkdownElement::new(markdown, style)
|
||||||
.on_code_span_link(callback)
|
.on_code_span_link(callback)
|
||||||
.code_block_renderer(CodeBlockRenderer::Default {
|
.code_block_renderer(CodeBlockRenderer::Default {
|
||||||
copy_button_visibility: CopyButtonVisibility::Hidden,
|
copy_button_visibility: CopyButtonVisibility::Hidden,
|
||||||
|
|
@ -4229,6 +4276,31 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_code_span_link_ignores_code_when_mouse_interaction_is_prevented(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) {
|
||||||
|
let callback_count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let rendered = render_markdown_with_code_span_link_style(
|
||||||
|
"see `foo.rs` for details",
|
||||||
|
MarkdownStyle {
|
||||||
|
prevent_mouse_interaction: true,
|
||||||
|
..MarkdownStyle::default()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let callback_count = callback_count.clone();
|
||||||
|
move |text, _cx| {
|
||||||
|
callback_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
(text == "foo.rs").then(|| "file:///tmp/foo.rs".into())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(rendered.links.is_empty());
|
||||||
|
assert_eq!(callback_count.load(Ordering::Relaxed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn test_code_span_link_ignores_code_without_callback(cx: &mut TestAppContext) {
|
fn test_code_span_link_ignores_code_without_callback(cx: &mut TestAppContext) {
|
||||||
let rendered = render_markdown("see `foo.rs` for details", cx);
|
let rendered = render_markdown("see `foo.rs` for details", cx);
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ pub struct ImportCursorSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const FIRST_OPEN: &str = "first_open";
|
pub const FIRST_OPEN: &str = "first_open";
|
||||||
pub const DOCS_URL: &str = "https://zed.dev/docs/";
|
|
||||||
|
|
||||||
actions!(
|
actions!(
|
||||||
onboarding,
|
onboarding,
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ pub enum Model {
|
||||||
Gemini3_1Pro,
|
Gemini3_1Pro,
|
||||||
#[serde(rename = "gemini-3-flash")]
|
#[serde(rename = "gemini-3-flash")]
|
||||||
Gemini3Flash,
|
Gemini3Flash,
|
||||||
|
#[serde(rename = "gemini-3.5-flash")]
|
||||||
|
Gemini3_5Flash,
|
||||||
|
|
||||||
// -- OpenAI Chat Completions protocol models --
|
// -- OpenAI Chat Completions protocol models --
|
||||||
#[serde(rename = "deepseek-v4-pro")]
|
#[serde(rename = "deepseek-v4-pro")]
|
||||||
|
|
@ -123,12 +125,12 @@ pub enum Model {
|
||||||
DeepSeekV4Flash,
|
DeepSeekV4Flash,
|
||||||
#[serde(rename = "minimax-m2.5")]
|
#[serde(rename = "minimax-m2.5")]
|
||||||
MiniMaxM2_5,
|
MiniMaxM2_5,
|
||||||
#[serde(rename = "minimax-m2.5-free")]
|
|
||||||
MiniMaxM2_5Free,
|
|
||||||
#[serde(rename = "glm-5")]
|
#[serde(rename = "glm-5")]
|
||||||
Glm5,
|
Glm5,
|
||||||
#[serde(rename = "glm-5.1")]
|
#[serde(rename = "glm-5.1")]
|
||||||
Glm5_1,
|
Glm5_1,
|
||||||
|
#[serde(rename = "grok-build-0.1")]
|
||||||
|
GrokBuild0_1,
|
||||||
#[serde(rename = "kimi-k2.5")]
|
#[serde(rename = "kimi-k2.5")]
|
||||||
KimiK2_5,
|
KimiK2_5,
|
||||||
#[serde(rename = "kimi-k2.6")]
|
#[serde(rename = "kimi-k2.6")]
|
||||||
|
|
@ -180,7 +182,7 @@ impl Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_free_fast() -> Self {
|
pub fn default_free_fast() -> Self {
|
||||||
Self::MiniMaxM2_5Free
|
Self::Nemotron3SuperFree
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn available_subscriptions(&self) -> &'static [OpenCodeSubscription] {
|
pub fn available_subscriptions(&self) -> &'static [OpenCodeSubscription] {
|
||||||
|
|
@ -202,9 +204,7 @@ impl Model {
|
||||||
| Self::DeepSeekV4Flash => &[OpenCodeSubscription::Go],
|
| Self::DeepSeekV4Flash => &[OpenCodeSubscription::Go],
|
||||||
|
|
||||||
// Free models
|
// Free models
|
||||||
Self::MiniMaxM2_5Free | Self::Nemotron3SuperFree | Self::BigPickle => {
|
Self::Nemotron3SuperFree | Self::BigPickle => &[OpenCodeSubscription::Free],
|
||||||
&[OpenCodeSubscription::Free]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom models get their subscription from settings, not from here
|
// Custom models get their subscription from settings, not from here
|
||||||
Self::Custom { .. } => &[],
|
Self::Custom { .. } => &[],
|
||||||
|
|
@ -245,13 +245,14 @@ impl Model {
|
||||||
|
|
||||||
Self::Gemini3_1Pro => "gemini-3.1-pro",
|
Self::Gemini3_1Pro => "gemini-3.1-pro",
|
||||||
Self::Gemini3Flash => "gemini-3-flash",
|
Self::Gemini3Flash => "gemini-3-flash",
|
||||||
|
Self::Gemini3_5Flash => "gemini-3.5-flash",
|
||||||
|
|
||||||
Self::DeepSeekV4Pro => "deepseek-v4-pro",
|
Self::DeepSeekV4Pro => "deepseek-v4-pro",
|
||||||
Self::DeepSeekV4Flash => "deepseek-v4-flash",
|
Self::DeepSeekV4Flash => "deepseek-v4-flash",
|
||||||
Self::MiniMaxM2_5 => "minimax-m2.5",
|
Self::MiniMaxM2_5 => "minimax-m2.5",
|
||||||
Self::MiniMaxM2_5Free => "minimax-m2.5-free",
|
|
||||||
Self::Glm5 => "glm-5",
|
Self::Glm5 => "glm-5",
|
||||||
Self::Glm5_1 => "glm-5.1",
|
Self::Glm5_1 => "glm-5.1",
|
||||||
|
Self::GrokBuild0_1 => "grok-build-0.1",
|
||||||
Self::KimiK2_5 => "kimi-k2.5",
|
Self::KimiK2_5 => "kimi-k2.5",
|
||||||
Self::KimiK2_6 => "kimi-k2.6",
|
Self::KimiK2_6 => "kimi-k2.6",
|
||||||
Self::MiniMaxM2_7 => "minimax-m2.7",
|
Self::MiniMaxM2_7 => "minimax-m2.7",
|
||||||
|
|
@ -297,13 +298,14 @@ impl Model {
|
||||||
|
|
||||||
Self::Gemini3_1Pro => "Gemini 3.1 Pro",
|
Self::Gemini3_1Pro => "Gemini 3.1 Pro",
|
||||||
Self::Gemini3Flash => "Gemini 3 Flash",
|
Self::Gemini3Flash => "Gemini 3 Flash",
|
||||||
|
Self::Gemini3_5Flash => "Gemini 3.5 Flash",
|
||||||
|
|
||||||
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
|
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
|
||||||
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
|
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
|
||||||
Self::MiniMaxM2_5 => "MiniMax M2.5",
|
Self::MiniMaxM2_5 => "MiniMax M2.5",
|
||||||
Self::MiniMaxM2_5Free => "MiniMax M2.5 Free",
|
|
||||||
Self::Glm5 => "GLM 5",
|
Self::Glm5 => "GLM 5",
|
||||||
Self::Glm5_1 => "GLM 5.1",
|
Self::Glm5_1 => "GLM 5.1",
|
||||||
|
Self::GrokBuild0_1 => "Grok Build 0.1",
|
||||||
Self::KimiK2_5 => "Kimi K2.5",
|
Self::KimiK2_5 => "Kimi K2.5",
|
||||||
Self::KimiK2_6 => "Kimi K2.6",
|
Self::KimiK2_6 => "Kimi K2.6",
|
||||||
Self::MiniMaxM2_7 => "MiniMax M2.7",
|
Self::MiniMaxM2_7 => "MiniMax M2.7",
|
||||||
|
|
@ -359,11 +361,11 @@ impl Model {
|
||||||
| Self::Gpt5Codex
|
| Self::Gpt5Codex
|
||||||
| Self::Gpt5Nano => ApiProtocol::OpenAiResponses,
|
| Self::Gpt5Nano => ApiProtocol::OpenAiResponses,
|
||||||
|
|
||||||
Self::Gemini3_1Pro | Self::Gemini3Flash => ApiProtocol::Google,
|
Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => ApiProtocol::Google,
|
||||||
|
|
||||||
Self::MiniMaxM2_5Free
|
Self::Glm5
|
||||||
| Self::Glm5
|
|
||||||
| Self::Glm5_1
|
| Self::Glm5_1
|
||||||
|
| Self::GrokBuild0_1
|
||||||
| Self::KimiK2_5
|
| Self::KimiK2_5
|
||||||
| Self::KimiK2_6
|
| Self::KimiK2_6
|
||||||
| Self::MimoV2_5Pro
|
| Self::MimoV2_5Pro
|
||||||
|
|
@ -426,10 +428,11 @@ impl Model {
|
||||||
// Google models
|
// Google models
|
||||||
Self::Gemini3_1Pro => 1_048_576,
|
Self::Gemini3_1Pro => 1_048_576,
|
||||||
Self::Gemini3Flash => 1_048_576,
|
Self::Gemini3Flash => 1_048_576,
|
||||||
|
Self::Gemini3_5Flash => 1_048_576,
|
||||||
|
|
||||||
// OpenAI-compatible models
|
// OpenAI-compatible models
|
||||||
Self::MiniMaxM2_7 => 204_800,
|
Self::MiniMaxM2_7 => 204_800,
|
||||||
Self::MiniMaxM2_5 | Self::MiniMaxM2_5Free => 204_800,
|
Self::MiniMaxM2_5 => 204_800,
|
||||||
Self::Glm5 | Self::Glm5_1 => {
|
Self::Glm5 | Self::Glm5_1 => {
|
||||||
if subscription == OpenCodeSubscription::Go {
|
if subscription == OpenCodeSubscription::Go {
|
||||||
202_752
|
202_752
|
||||||
|
|
@ -438,6 +441,7 @@ impl Model {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::KimiK2_6 | Self::KimiK2_5 => 262_144,
|
Self::KimiK2_6 | Self::KimiK2_5 => 262_144,
|
||||||
|
Self::GrokBuild0_1 => 256_000,
|
||||||
Self::MimoV2_5Pro => 1_048_576,
|
Self::MimoV2_5Pro => 1_048_576,
|
||||||
Self::MimoV2_5 => 1_000_000,
|
Self::MimoV2_5 => 1_000_000,
|
||||||
Self::Qwen3_5Plus | Self::Qwen3_6Plus => 262_144,
|
Self::Qwen3_5Plus | Self::Qwen3_6Plus => 262_144,
|
||||||
|
|
@ -480,11 +484,10 @@ impl Model {
|
||||||
| Self::Gpt5Nano => Some(128_000),
|
| Self::Gpt5Nano => Some(128_000),
|
||||||
|
|
||||||
// Google models
|
// Google models
|
||||||
Self::Gemini3_1Pro | Self::Gemini3Flash => Some(65_536),
|
Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => Some(65_536),
|
||||||
|
|
||||||
// OpenAI-compatible models
|
// OpenAI-compatible models
|
||||||
Self::MiniMaxM2_7 => Some(131_072),
|
Self::MiniMaxM2_7 => Some(131_072),
|
||||||
Self::MiniMaxM2_5Free => Some(131_072),
|
|
||||||
Self::MiniMaxM2_5 => {
|
Self::MiniMaxM2_5 => {
|
||||||
if subscription == OpenCodeSubscription::Go {
|
if subscription == OpenCodeSubscription::Go {
|
||||||
Some(65_536)
|
Some(65_536)
|
||||||
|
|
@ -501,6 +504,7 @@ impl Model {
|
||||||
}
|
}
|
||||||
Self::BigPickle => Some(128_000),
|
Self::BigPickle => Some(128_000),
|
||||||
Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536),
|
Self::KimiK2_6 | Self::KimiK2_5 => Some(65_536),
|
||||||
|
Self::GrokBuild0_1 => Some(256_000),
|
||||||
Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536),
|
Self::Qwen3_5Plus | Self::Qwen3_6Plus => Some(65_536),
|
||||||
Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000),
|
Self::DeepSeekV4Pro | Self::DeepSeekV4Flash => Some(384_000),
|
||||||
Self::Nemotron3SuperFree => Some(128_000),
|
Self::Nemotron3SuperFree => Some(128_000),
|
||||||
|
|
@ -550,18 +554,18 @@ impl Model {
|
||||||
Self::Gpt5_3Spark => false,
|
Self::Gpt5_3Spark => false,
|
||||||
|
|
||||||
// Google models support images
|
// Google models support images
|
||||||
Self::Gemini3_1Pro | Self::Gemini3Flash => true,
|
Self::Gemini3_1Pro | Self::Gemini3Flash | Self::Gemini3_5Flash => true,
|
||||||
|
|
||||||
// OpenAI-compatible models with image support
|
// OpenAI-compatible models with image support
|
||||||
Self::KimiK2_6
|
Self::KimiK2_6
|
||||||
| Self::KimiK2_5
|
| Self::KimiK2_5
|
||||||
|
| Self::GrokBuild0_1
|
||||||
| Self::MimoV2_5
|
| Self::MimoV2_5
|
||||||
| Self::Qwen3_5Plus
|
| Self::Qwen3_5Plus
|
||||||
| Self::Qwen3_6Plus => true,
|
| Self::Qwen3_6Plus => true,
|
||||||
|
|
||||||
// OpenAI-compatible models without image support
|
// OpenAI-compatible models without image support
|
||||||
Self::MiniMaxM2_5
|
Self::MiniMaxM2_5
|
||||||
| Self::MiniMaxM2_5Free
|
|
||||||
| Self::Glm5
|
| Self::Glm5
|
||||||
| Self::Glm5_1
|
| Self::Glm5_1
|
||||||
| Self::MiniMaxM2_7
|
| Self::MiniMaxM2_7
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,11 @@ use git::{
|
||||||
blame::Blame,
|
blame::Blame,
|
||||||
parse_git_remote_url,
|
parse_git_remote_url,
|
||||||
repository::{
|
repository::{
|
||||||
Branch, CommitData, CommitDetails, CommitDiff, CommitFile, CommitOptions,
|
Branch, BranchesScanResult, CommitData, CommitDetails, CommitDiff, CommitFile,
|
||||||
CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository,
|
CommitOptions, CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate,
|
||||||
GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
|
GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource,
|
||||||
RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus,
|
PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs,
|
||||||
Worktree as GitWorktree, delete_branch_flag,
|
UpstreamTrackingStatus, Worktree as GitWorktree, delete_branch_flag,
|
||||||
},
|
},
|
||||||
stash::{GitStash, StashEntry},
|
stash::{GitStash, StashEntry},
|
||||||
status::{
|
status::{
|
||||||
|
|
@ -302,6 +302,7 @@ pub struct RepositorySnapshot {
|
||||||
pub id: RepositoryId,
|
pub id: RepositoryId,
|
||||||
pub statuses_by_path: SumTree<StatusEntry>,
|
pub statuses_by_path: SumTree<StatusEntry>,
|
||||||
pub work_directory_abs_path: Arc<Path>,
|
pub work_directory_abs_path: Arc<Path>,
|
||||||
|
pub dot_git_abs_path: Arc<Path>,
|
||||||
/// Absolute path to the directory holding this worktree's Git state.
|
/// Absolute path to the directory holding this worktree's Git state.
|
||||||
///
|
///
|
||||||
/// For a linked worktree this is the worktree-specific directory under the
|
/// For a linked worktree this is the worktree-specific directory under the
|
||||||
|
|
@ -317,6 +318,7 @@ pub struct RepositorySnapshot {
|
||||||
pub path_style: PathStyle,
|
pub path_style: PathStyle,
|
||||||
pub branch: Option<Branch>,
|
pub branch: Option<Branch>,
|
||||||
pub branch_list: Arc<[Branch]>,
|
pub branch_list: Arc<[Branch]>,
|
||||||
|
pub branch_list_error: Option<SharedString>,
|
||||||
pub head_commit: Option<CommitDetails>,
|
pub head_commit: Option<CommitDetails>,
|
||||||
pub scan_id: u64,
|
pub scan_id: u64,
|
||||||
pub merge: MergeDetails,
|
pub merge: MergeDetails,
|
||||||
|
|
@ -380,6 +382,7 @@ pub struct Repository {
|
||||||
// and that should be examined during the next status scan.
|
// and that should be examined during the next status scan.
|
||||||
paths_needing_status_update: Vec<Vec<RepoPath>>,
|
paths_needing_status_update: Vec<Vec<RepoPath>>,
|
||||||
job_sender: mpsc::UnboundedSender<GitJob>,
|
job_sender: mpsc::UnboundedSender<GitJob>,
|
||||||
|
_worker_task: Task<()>,
|
||||||
active_jobs: HashMap<JobId, JobInfo>,
|
active_jobs: HashMap<JobId, JobInfo>,
|
||||||
job_debug_queue: job_debug_queue::GitJobDebugQueue,
|
job_debug_queue: job_debug_queue::GitJobDebugQueue,
|
||||||
pending_ops: SumTree<PendingOps>,
|
pending_ops: SumTree<PendingOps>,
|
||||||
|
|
@ -390,14 +393,6 @@ pub struct Repository {
|
||||||
initial_graph_data: HashMap<(LogSource, LogOrder), InitialGitGraphData>,
|
initial_graph_data: HashMap<(LogSource, LogOrder), InitialGitGraphData>,
|
||||||
commit_data_handler: CommitDataHandlerState,
|
commit_data_handler: CommitDataHandlerState,
|
||||||
commit_data: HashMap<Oid, CommitDataState>,
|
commit_data: HashMap<Oid, CommitDataState>,
|
||||||
refetch_repo_state: Arc<
|
|
||||||
dyn Fn(
|
|
||||||
&mut Context<Self>,
|
|
||||||
) -> (
|
|
||||||
mpsc::UnboundedSender<GitJob>,
|
|
||||||
Shared<Task<Result<RepositoryState, String>>>,
|
|
||||||
),
|
|
||||||
>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::ops::Deref for Repository {
|
impl std::ops::Deref for Repository {
|
||||||
|
|
@ -546,14 +541,36 @@ impl GitStore {
|
||||||
let (mut watcher, _) = watcher.await;
|
let (mut watcher, _) = watcher.await;
|
||||||
while let Some(_) = watcher.next().await {
|
while let Some(_) = watcher.next().await {
|
||||||
let Ok(_) = this.update(cx, |this, cx| {
|
let Ok(_) = this.update(cx, |this, cx| {
|
||||||
for repo in this.repositories.values() {
|
let GitStoreState::Local {
|
||||||
repo.update(cx, |this, cx| {
|
project_environment,
|
||||||
if this.job_sender.is_closed() {
|
fs,
|
||||||
let (job_sender, state) = (this.refetch_repo_state)(cx);
|
..
|
||||||
this.repository_state = state;
|
} = &this.state
|
||||||
this.job_sender = job_sender;
|
else {
|
||||||
this.schedule_scan(None, cx);
|
return;
|
||||||
}
|
};
|
||||||
|
let project_environment = project_environment.downgrade();
|
||||||
|
let fs = fs.clone();
|
||||||
|
let repositories_to_respawn = this
|
||||||
|
.repositories
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(repository_id, repo)| {
|
||||||
|
repo.read(cx)
|
||||||
|
.job_sender
|
||||||
|
.is_closed()
|
||||||
|
.then_some((*repository_id, repo.clone()))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for (repository_id, repo) in repositories_to_respawn {
|
||||||
|
let is_trusted = this.repository_is_trusted(repository_id, cx);
|
||||||
|
repo.update(cx, |repo, cx| {
|
||||||
|
repo.respawn_local_worker(
|
||||||
|
project_environment.clone(),
|
||||||
|
fs.clone(),
|
||||||
|
is_trusted,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
repo.schedule_scan(None, cx);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
cx.emit(GitStoreEvent::GlobalConfigurationUpdated);
|
cx.emit(GitStoreEvent::GlobalConfigurationUpdated);
|
||||||
|
|
@ -1598,6 +1615,21 @@ impl GitStore {
|
||||||
cx.emit(GitStoreEvent::JobsUpdated)
|
cx.emit(GitStoreEvent::JobsUpdated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repository_is_trusted(&self, repository_id: RepositoryId, cx: &mut Context<Self>) -> bool {
|
||||||
|
let Some(worktree_ids) = self.worktree_ids.get(&repository_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
worktree_ids.iter().any(|worktree_id| {
|
||||||
|
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||||
|
trusted_worktrees.can_trust(&self.worktree_store, *worktree_id, cx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Update our list of repositories and schedule git scans in response to a notification from a worktree,
|
/// Update our list of repositories and schedule git scans in response to a notification from a worktree,
|
||||||
fn update_repositories_from_worktree(
|
fn update_repositories_from_worktree(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -1627,10 +1659,44 @@ impl GitStore {
|
||||||
.entry(repo_id)
|
.entry(repo_id)
|
||||||
.or_insert_with(HashSet::new)
|
.or_insert_with(HashSet::new)
|
||||||
.insert(worktree_id);
|
.insert(worktree_id);
|
||||||
existing.update(cx, |existing, cx| {
|
let path_changed = update.old_work_directory_abs_path.as_ref()
|
||||||
existing.snapshot.work_directory_abs_path = new_work_directory_abs_path;
|
!= update.new_work_directory_abs_path.as_ref();
|
||||||
existing.schedule_scan(updates_tx.clone(), cx);
|
if path_changed
|
||||||
});
|
&& let Some(dot_git_abs_path) = update.dot_git_abs_path.clone()
|
||||||
|
&& let Some(repository_dir_abs_path) =
|
||||||
|
update.repository_dir_abs_path.clone()
|
||||||
|
&& let Some(common_dir_abs_path) = update.common_dir_abs_path.clone()
|
||||||
|
{
|
||||||
|
let is_trusted = TrustedWorktrees::try_get_global(cx)
|
||||||
|
.map(|trusted_worktrees| {
|
||||||
|
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||||
|
trusted_worktrees.can_trust(
|
||||||
|
&self.worktree_store,
|
||||||
|
worktree_id,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
existing.update(cx, |existing, cx| {
|
||||||
|
existing.reinitialize_local_backend(
|
||||||
|
new_work_directory_abs_path,
|
||||||
|
dot_git_abs_path,
|
||||||
|
repository_dir_abs_path,
|
||||||
|
common_dir_abs_path,
|
||||||
|
project_environment.downgrade(),
|
||||||
|
fs.clone(),
|
||||||
|
is_trusted,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
existing.schedule_scan(updates_tx.clone(), cx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
existing.update(cx, |existing, cx| {
|
||||||
|
existing.snapshot.work_directory_abs_path = new_work_directory_abs_path;
|
||||||
|
existing.schedule_scan(updates_tx.clone(), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if let Some(worktree_ids) = self.worktree_ids.get_mut(&repo_id) {
|
if let Some(worktree_ids) = self.worktree_ids.get_mut(&repo_id) {
|
||||||
worktree_ids.remove(&worktree_id);
|
worktree_ids.remove(&worktree_id);
|
||||||
|
|
@ -2877,15 +2943,17 @@ impl GitStore {
|
||||||
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
|
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
|
||||||
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
|
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
|
||||||
|
|
||||||
let branches = repository_handle
|
let branches_scan = repository_handle
|
||||||
.update(&mut cx, |repository_handle, _| repository_handle.branches())
|
.update(&mut cx, |repository_handle, _| repository_handle.branches())
|
||||||
.await??;
|
.await??;
|
||||||
|
|
||||||
Ok(proto::GitBranchesResponse {
|
Ok(proto::GitBranchesResponse {
|
||||||
branches: branches
|
branches: branches_scan
|
||||||
|
.branches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|branch| branch_to_proto(&branch))
|
.map(|branch| branch_to_proto(&branch))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
|
error: branches_scan.error.map(|error| error.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async fn handle_get_default_branch(
|
async fn handle_get_default_branch(
|
||||||
|
|
@ -4104,11 +4172,14 @@ impl RepositorySnapshot {
|
||||||
id: RepositoryId,
|
id: RepositoryId,
|
||||||
work_directory_abs_path: Arc<Path>,
|
work_directory_abs_path: Arc<Path>,
|
||||||
repository_dir_abs_path: Option<Arc<Path>>,
|
repository_dir_abs_path: Option<Arc<Path>>,
|
||||||
|
dot_git_abs_path: Option<Arc<Path>>,
|
||||||
common_dir_abs_path: Option<Arc<Path>>,
|
common_dir_abs_path: Option<Arc<Path>>,
|
||||||
path_style: PathStyle,
|
path_style: PathStyle,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let repository_dir_abs_path =
|
let repository_dir_abs_path =
|
||||||
repository_dir_abs_path.unwrap_or_else(|| work_directory_abs_path.join(".git").into());
|
repository_dir_abs_path.unwrap_or_else(|| work_directory_abs_path.join(".git").into());
|
||||||
|
let dot_git_abs_path =
|
||||||
|
dot_git_abs_path.unwrap_or_else(|| work_directory_abs_path.join(".git").into());
|
||||||
let common_dir_abs_path =
|
let common_dir_abs_path =
|
||||||
common_dir_abs_path.unwrap_or_else(|| repository_dir_abs_path.clone());
|
common_dir_abs_path.unwrap_or_else(|| repository_dir_abs_path.clone());
|
||||||
|
|
||||||
|
|
@ -4116,10 +4187,12 @@ impl RepositorySnapshot {
|
||||||
id,
|
id,
|
||||||
statuses_by_path: Default::default(),
|
statuses_by_path: Default::default(),
|
||||||
repository_dir_abs_path,
|
repository_dir_abs_path,
|
||||||
|
dot_git_abs_path,
|
||||||
common_dir_abs_path,
|
common_dir_abs_path,
|
||||||
work_directory_abs_path,
|
work_directory_abs_path,
|
||||||
branch: None,
|
branch: None,
|
||||||
branch_list: Arc::from([]),
|
branch_list: Arc::from([]),
|
||||||
|
branch_list_error: None,
|
||||||
head_commit: None,
|
head_commit: None,
|
||||||
scan_id: 0,
|
scan_id: 0,
|
||||||
merge: Default::default(),
|
merge: Default::default(),
|
||||||
|
|
@ -4135,6 +4208,10 @@ impl RepositorySnapshot {
|
||||||
proto::UpdateRepository {
|
proto::UpdateRepository {
|
||||||
branch_summary: self.branch.as_ref().map(branch_to_proto),
|
branch_summary: self.branch.as_ref().map(branch_to_proto),
|
||||||
branch_list: self.branch_list.iter().map(branch_to_proto).collect(),
|
branch_list: self.branch_list.iter().map(branch_to_proto).collect(),
|
||||||
|
branch_list_error: self
|
||||||
|
.branch_list_error
|
||||||
|
.as_ref()
|
||||||
|
.map(|error| error.to_string()),
|
||||||
head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto),
|
head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto),
|
||||||
updated_statuses: self
|
updated_statuses: self
|
||||||
.statuses_by_path
|
.statuses_by_path
|
||||||
|
|
@ -4222,6 +4299,10 @@ impl RepositorySnapshot {
|
||||||
proto::UpdateRepository {
|
proto::UpdateRepository {
|
||||||
branch_summary: self.branch.as_ref().map(branch_to_proto),
|
branch_summary: self.branch.as_ref().map(branch_to_proto),
|
||||||
branch_list: self.branch_list.iter().map(branch_to_proto).collect(),
|
branch_list: self.branch_list.iter().map(branch_to_proto).collect(),
|
||||||
|
branch_list_error: self
|
||||||
|
.branch_list_error
|
||||||
|
.as_ref()
|
||||||
|
.map(|error| error.to_string()),
|
||||||
head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto),
|
head_commit_details: self.head_commit.as_ref().map(commit_details_to_proto),
|
||||||
updated_statuses,
|
updated_statuses,
|
||||||
removed_statuses,
|
removed_statuses,
|
||||||
|
|
@ -4394,7 +4475,7 @@ impl MergeDetails {
|
||||||
&mut self,
|
&mut self,
|
||||||
backend: &Arc<dyn GitRepository>,
|
backend: &Arc<dyn GitRepository>,
|
||||||
current_conflicted_paths: Vec<RepoPath>,
|
current_conflicted_paths: Vec<RepoPath>,
|
||||||
) -> Result<bool> {
|
) -> bool {
|
||||||
log::debug!("load merge details");
|
log::debug!("load merge details");
|
||||||
self.message = backend.merge_message().await.map(SharedString::from);
|
self.message = backend.merge_message().await.map(SharedString::from);
|
||||||
let heads = backend
|
let heads = backend
|
||||||
|
|
@ -4435,7 +4516,7 @@ impl MergeDetails {
|
||||||
keep
|
keep
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(conflicts_changed)
|
conflicts_changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4465,6 +4546,66 @@ impl Repository {
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn respawn_local_worker(
|
||||||
|
&mut self,
|
||||||
|
project_environment: WeakEntity<ProjectEnvironment>,
|
||||||
|
fs: Arc<dyn Fs>,
|
||||||
|
is_trusted: bool,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let work_directory_abs_path = self.snapshot.work_directory_abs_path.clone();
|
||||||
|
let dot_git_abs_path = self.snapshot.dot_git_abs_path.clone();
|
||||||
|
|
||||||
|
let state = cx
|
||||||
|
.spawn(async move |_, cx| {
|
||||||
|
LocalRepositoryState::new(
|
||||||
|
work_directory_abs_path,
|
||||||
|
dot_git_abs_path,
|
||||||
|
project_environment,
|
||||||
|
fs,
|
||||||
|
is_trusted,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
})
|
||||||
|
.shared();
|
||||||
|
self.job_sender.close_channel();
|
||||||
|
self._worker_task = Task::ready(());
|
||||||
|
self.active_jobs.clear();
|
||||||
|
self.job_debug_queue
|
||||||
|
.mark_unfinished_complete(job_debug_queue::CompletedJobStatus::Skipped);
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let (job_sender, worker_task) = Repository::spawn_local_git_worker(state.clone(), cx);
|
||||||
|
self.job_sender = job_sender;
|
||||||
|
self._worker_task = worker_task;
|
||||||
|
self.repository_state = cx
|
||||||
|
.spawn(async move |_, _| {
|
||||||
|
let state = state.await?;
|
||||||
|
Ok(RepositoryState::Local(state))
|
||||||
|
})
|
||||||
|
.shared();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reinitialize_local_backend(
|
||||||
|
&mut self,
|
||||||
|
work_directory_abs_path: Arc<Path>,
|
||||||
|
dot_git_abs_path: Arc<Path>,
|
||||||
|
repository_dir_abs_path: Arc<Path>,
|
||||||
|
common_dir_abs_path: Arc<Path>,
|
||||||
|
project_environment: WeakEntity<ProjectEnvironment>,
|
||||||
|
fs: Arc<dyn Fs>,
|
||||||
|
is_trusted: bool,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.snapshot.work_directory_abs_path = work_directory_abs_path;
|
||||||
|
self.snapshot.dot_git_abs_path = dot_git_abs_path;
|
||||||
|
self.snapshot.repository_dir_abs_path = repository_dir_abs_path;
|
||||||
|
self.snapshot.common_dir_abs_path = common_dir_abs_path;
|
||||||
|
self.respawn_local_worker(project_environment, fs, is_trusted, cx);
|
||||||
|
}
|
||||||
|
|
||||||
fn local(
|
fn local(
|
||||||
id: RepositoryId,
|
id: RepositoryId,
|
||||||
work_directory_abs_path: Arc<Path>,
|
work_directory_abs_path: Arc<Path>,
|
||||||
|
|
@ -4479,64 +4620,35 @@ impl Repository {
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let snapshot = RepositorySnapshot::empty(
|
let snapshot = RepositorySnapshot::empty(
|
||||||
id,
|
id,
|
||||||
work_directory_abs_path.clone(),
|
work_directory_abs_path,
|
||||||
Some(repository_dir_abs_path),
|
Some(repository_dir_abs_path),
|
||||||
|
Some(dot_git_abs_path),
|
||||||
Some(common_dir_abs_path),
|
Some(common_dir_abs_path),
|
||||||
PathStyle::local(),
|
PathStyle::local(),
|
||||||
);
|
);
|
||||||
let refetch_repo_state = Arc::new(move |cx: &mut Context<Self>| {
|
|
||||||
let work_directory_abs_path = work_directory_abs_path.clone();
|
|
||||||
let dot_git_abs_path = dot_git_abs_path.clone();
|
|
||||||
let project_environment = project_environment.clone();
|
|
||||||
let fs = fs.clone();
|
|
||||||
|
|
||||||
let state = cx
|
let mut repo = Repository {
|
||||||
.spawn(async move |_, cx| {
|
|
||||||
LocalRepositoryState::new(
|
|
||||||
work_directory_abs_path,
|
|
||||||
dot_git_abs_path,
|
|
||||||
project_environment,
|
|
||||||
fs,
|
|
||||||
is_trusted,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| err.to_string())
|
|
||||||
})
|
|
||||||
.shared();
|
|
||||||
let job_sender = Repository::spawn_local_git_worker(state.clone(), cx);
|
|
||||||
let state = cx
|
|
||||||
.spawn(async move |_, _| {
|
|
||||||
let state = state.await?;
|
|
||||||
Ok(RepositoryState::Local(state))
|
|
||||||
})
|
|
||||||
.shared();
|
|
||||||
|
|
||||||
(job_sender, state)
|
|
||||||
});
|
|
||||||
|
|
||||||
let (job_sender, state) = (refetch_repo_state)(cx);
|
|
||||||
cx.subscribe_self(Self::handle_subscribe_self).detach();
|
|
||||||
|
|
||||||
Repository {
|
|
||||||
this: cx.weak_entity(),
|
this: cx.weak_entity(),
|
||||||
git_store,
|
git_store,
|
||||||
snapshot,
|
snapshot,
|
||||||
pending_ops: Default::default(),
|
pending_ops: Default::default(),
|
||||||
repository_state: state,
|
repository_state: Task::ready(Err("not yet initialized".into())).shared(),
|
||||||
|
_worker_task: Task::ready(()),
|
||||||
commit_message_buffer: None,
|
commit_message_buffer: None,
|
||||||
askpass_delegates: Default::default(),
|
askpass_delegates: Default::default(),
|
||||||
paths_needing_status_update: Default::default(),
|
paths_needing_status_update: Default::default(),
|
||||||
latest_askpass_id: 0,
|
latest_askpass_id: 0,
|
||||||
job_sender,
|
job_sender: mpsc::unbounded().0,
|
||||||
job_id: 0,
|
job_id: 0,
|
||||||
active_jobs: Default::default(),
|
active_jobs: Default::default(),
|
||||||
job_debug_queue: job_debug_queue::GitJobDebugQueue::new(),
|
job_debug_queue: job_debug_queue::GitJobDebugQueue::new(),
|
||||||
initial_graph_data: Default::default(),
|
initial_graph_data: Default::default(),
|
||||||
commit_data: Default::default(),
|
commit_data: Default::default(),
|
||||||
commit_data_handler: CommitDataHandlerState::Closed,
|
commit_data_handler: CommitDataHandlerState::Closed,
|
||||||
refetch_repo_state,
|
};
|
||||||
}
|
repo.respawn_local_worker(project_environment, fs, is_trusted, cx);
|
||||||
|
cx.subscribe_self(Self::handle_subscribe_self).detach();
|
||||||
|
repo
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remote(
|
fn remote(
|
||||||
|
|
@ -4554,21 +4666,14 @@ impl Repository {
|
||||||
id,
|
id,
|
||||||
work_directory_abs_path,
|
work_directory_abs_path,
|
||||||
repository_dir_abs_path,
|
repository_dir_abs_path,
|
||||||
|
None,
|
||||||
common_dir_abs_path,
|
common_dir_abs_path,
|
||||||
path_style,
|
path_style,
|
||||||
);
|
);
|
||||||
let refetch_repo_state = Arc::new(move |cx: &mut Context<Self>| {
|
|
||||||
let repository_state = RemoteRepositoryState {
|
|
||||||
project_id,
|
|
||||||
client: client.clone(),
|
|
||||||
};
|
|
||||||
let job_sender = Self::spawn_remote_git_worker(repository_state.clone(), cx);
|
|
||||||
let repository_state =
|
|
||||||
Task::ready(Ok(RepositoryState::Remote(repository_state))).shared();
|
|
||||||
(job_sender, repository_state)
|
|
||||||
});
|
|
||||||
|
|
||||||
let (job_sender, repository_state) = (refetch_repo_state)(cx);
|
let repository_state = RemoteRepositoryState { project_id, client };
|
||||||
|
let (job_sender, worker_task) = Self::spawn_remote_git_worker(repository_state.clone(), cx);
|
||||||
|
let repository_state = Task::ready(Ok(RepositoryState::Remote(repository_state))).shared();
|
||||||
cx.subscribe_self(Self::handle_subscribe_self).detach();
|
cx.subscribe_self(Self::handle_subscribe_self).detach();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -4579,6 +4684,7 @@ impl Repository {
|
||||||
pending_ops: Default::default(),
|
pending_ops: Default::default(),
|
||||||
paths_needing_status_update: Default::default(),
|
paths_needing_status_update: Default::default(),
|
||||||
job_sender,
|
job_sender,
|
||||||
|
_worker_task: worker_task,
|
||||||
repository_state,
|
repository_state,
|
||||||
askpass_delegates: Default::default(),
|
askpass_delegates: Default::default(),
|
||||||
latest_askpass_id: 0,
|
latest_askpass_id: 0,
|
||||||
|
|
@ -4588,7 +4694,6 @@ impl Repository {
|
||||||
initial_graph_data: Default::default(),
|
initial_graph_data: Default::default(),
|
||||||
commit_data: Default::default(),
|
commit_data: Default::default(),
|
||||||
commit_data_handler: CommitDataHandlerState::Closed,
|
commit_data_handler: CommitDataHandlerState::Closed,
|
||||||
refetch_repo_state,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6516,12 +6621,23 @@ impl Repository {
|
||||||
.await;
|
.await;
|
||||||
// TODO would be nice to not have to do this manually
|
// TODO would be nice to not have to do this manually
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
let branches = backend.branches().await?;
|
let branches_scan = backend.branches().await?;
|
||||||
let branch = branches.into_iter().find(|branch| branch.is_head);
|
let branch_list_error = branches_scan.error;
|
||||||
|
let branch_list: Arc<[Branch]> = branches_scan.branches.into();
|
||||||
|
let branch = branch_list.iter().find(|branch| branch.is_head).cloned();
|
||||||
log::info!("head branch after scan is {branch:?}");
|
log::info!("head branch after scan is {branch:?}");
|
||||||
let snapshot = this.update(&mut cx, |this, cx| {
|
let snapshot = this.update(&mut cx, |this, cx| {
|
||||||
|
let branch_list_changed =
|
||||||
|
*branch_list != *this.snapshot.branch_list;
|
||||||
|
let branch_list_error_changed =
|
||||||
|
this.snapshot.branch_list_error != branch_list_error;
|
||||||
this.snapshot.branch = branch;
|
this.snapshot.branch = branch;
|
||||||
|
this.snapshot.branch_list = branch_list;
|
||||||
|
this.snapshot.branch_list_error = branch_list_error;
|
||||||
cx.emit(RepositoryEvent::HeadChanged);
|
cx.emit(RepositoryEvent::HeadChanged);
|
||||||
|
if branch_list_changed || branch_list_error_changed {
|
||||||
|
cx.emit(RepositoryEvent::BranchListChanged);
|
||||||
|
}
|
||||||
this.snapshot.clone()
|
this.snapshot.clone()
|
||||||
})?;
|
})?;
|
||||||
if let Some(updates_tx) = updates_tx {
|
if let Some(updates_tx) = updates_tx {
|
||||||
|
|
@ -6820,7 +6936,7 @@ impl Repository {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn branches(&mut self) -> oneshot::Receiver<Result<Vec<Branch>>> {
|
pub fn branches(&mut self) -> oneshot::Receiver<Result<BranchesScanResult>> {
|
||||||
let id = self.id;
|
let id = self.id;
|
||||||
self.send_job("branches", None, move |repo, _| async move {
|
self.send_job("branches", None, move |repo, _| async move {
|
||||||
match repo {
|
match repo {
|
||||||
|
|
@ -6841,7 +6957,10 @@ impl Repository {
|
||||||
.map(|branch| proto_to_branch(&branch))
|
.map(|branch| proto_to_branch(&branch))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(branches)
|
Ok(BranchesScanResult {
|
||||||
|
branches,
|
||||||
|
error: response.error.map(SharedString::from),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -7608,10 +7727,14 @@ impl Repository {
|
||||||
if update.is_last_update {
|
if update.is_last_update {
|
||||||
let new_branch_list: Arc<[Branch]> =
|
let new_branch_list: Arc<[Branch]> =
|
||||||
update.branch_list.iter().map(proto_to_branch).collect();
|
update.branch_list.iter().map(proto_to_branch).collect();
|
||||||
if *self.snapshot.branch_list != *new_branch_list {
|
let new_branch_list_error = update.branch_list_error.map(SharedString::from);
|
||||||
|
if *self.snapshot.branch_list != *new_branch_list
|
||||||
|
|| self.snapshot.branch_list_error != new_branch_list_error
|
||||||
|
{
|
||||||
cx.emit(RepositoryEvent::BranchListChanged);
|
cx.emit(RepositoryEvent::BranchListChanged);
|
||||||
}
|
}
|
||||||
self.snapshot.branch_list = new_branch_list;
|
self.snapshot.branch_list = new_branch_list;
|
||||||
|
self.snapshot.branch_list_error = new_branch_list_error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't store any merge head state for downstream projects; the upstream
|
// We don't store any merge head state for downstream projects; the upstream
|
||||||
|
|
@ -7777,7 +7900,7 @@ impl Repository {
|
||||||
let RepositoryState::Local(LocalRepositoryState { backend, .. }) = state else {
|
let RepositoryState::Local(LocalRepositoryState { backend, .. }) = state else {
|
||||||
bail!("not a local repository")
|
bail!("not a local repository")
|
||||||
};
|
};
|
||||||
let snapshot = compute_snapshot(this.clone(), backend.clone(), &mut cx).await?;
|
let snapshot = compute_snapshot(this.clone(), backend.clone(), &mut cx).await;
|
||||||
this.update(&mut cx, |this, cx| {
|
this.update(&mut cx, |this, cx| {
|
||||||
this.clear_pending_ops(cx);
|
this.clear_pending_ops(cx);
|
||||||
});
|
});
|
||||||
|
|
@ -7794,11 +7917,13 @@ impl Repository {
|
||||||
fn spawn_local_git_worker(
|
fn spawn_local_git_worker(
|
||||||
state: Shared<Task<Result<LocalRepositoryState, String>>>,
|
state: Shared<Task<Result<LocalRepositoryState, String>>>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> mpsc::UnboundedSender<GitJob> {
|
) -> (mpsc::UnboundedSender<GitJob>, Task<()>) {
|
||||||
let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
|
let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let worker_task = cx.spawn(async move |this, cx| {
|
||||||
let state = state.await.map_err(|err| anyhow::anyhow!(err))?;
|
let Some(state) = state.await.log_err() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if let Some(git_hosting_provider_registry) =
|
if let Some(git_hosting_provider_registry) =
|
||||||
cx.update(|cx| GitHostingProviderRegistry::try_global(cx))
|
cx.update(|cx| GitHostingProviderRegistry::try_global(cx))
|
||||||
{
|
{
|
||||||
|
|
@ -7838,55 +7963,56 @@ impl Repository {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
anyhow::Ok(())
|
});
|
||||||
})
|
|
||||||
.detach_and_log_err(cx);
|
|
||||||
|
|
||||||
job_tx
|
(job_tx, worker_task)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_remote_git_worker(
|
fn spawn_remote_git_worker(
|
||||||
state: RemoteRepositoryState,
|
state: RemoteRepositoryState,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> mpsc::UnboundedSender<GitJob> {
|
) -> (mpsc::UnboundedSender<GitJob>, Task<()>) {
|
||||||
let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
|
let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let worker_task = cx.spawn(async move |this, cx| {
|
||||||
let state = RepositoryState::Remote(state);
|
let result: Result<()> = async {
|
||||||
let mut jobs = VecDeque::new();
|
let state = RepositoryState::Remote(state);
|
||||||
loop {
|
let mut jobs = VecDeque::new();
|
||||||
while let Ok(next_job) = job_rx.try_recv() {
|
loop {
|
||||||
jobs.push_back(next_job);
|
while let Ok(next_job) = job_rx.try_recv() {
|
||||||
}
|
jobs.push_back(next_job);
|
||||||
|
|
||||||
if let Some(job) = jobs.pop_front() {
|
|
||||||
if let Some(current_key) = &job.key
|
|
||||||
&& jobs
|
|
||||||
.iter()
|
|
||||||
.any(|other_job| other_job.key.as_ref() == Some(current_key))
|
|
||||||
{
|
|
||||||
let skipped_job_id = job.id;
|
|
||||||
this.update(cx, |repo, _| {
|
|
||||||
repo.job_debug_queue.mark_complete(
|
|
||||||
skipped_job_id,
|
|
||||||
job_debug_queue::CompletedJobStatus::Skipped,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
(job.job)(state.clone(), cx).await;
|
|
||||||
} else if let Some(job) = job_rx.next().await {
|
|
||||||
jobs.push_back(job);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
anyhow::Ok(())
|
|
||||||
})
|
|
||||||
.detach_and_log_err(cx);
|
|
||||||
|
|
||||||
job_tx
|
if let Some(job) = jobs.pop_front() {
|
||||||
|
if let Some(current_key) = &job.key
|
||||||
|
&& jobs
|
||||||
|
.iter()
|
||||||
|
.any(|other_job| other_job.key.as_ref() == Some(current_key))
|
||||||
|
{
|
||||||
|
let skipped_job_id = job.id;
|
||||||
|
this.update(cx, |repo, _| {
|
||||||
|
repo.job_debug_queue.mark_complete(
|
||||||
|
skipped_job_id,
|
||||||
|
job_debug_queue::CompletedJobStatus::Skipped,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(job.job)(state.clone(), cx).await;
|
||||||
|
} else if let Some(job) = job_rx.next().await {
|
||||||
|
jobs.push_back(job);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
result.log_err();
|
||||||
|
});
|
||||||
|
|
||||||
|
(job_tx, worker_task)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_staged_text(
|
fn load_staged_text(
|
||||||
|
|
@ -9143,7 +9269,7 @@ async fn compute_snapshot(
|
||||||
this: Entity<Repository>,
|
this: Entity<Repository>,
|
||||||
backend: Arc<dyn GitRepository>,
|
backend: Arc<dyn GitRepository>,
|
||||||
cx: &mut AsyncApp,
|
cx: &mut AsyncApp,
|
||||||
) -> Result<RepositorySnapshot> {
|
) -> RepositorySnapshot {
|
||||||
log::debug!("starting compute snapshot");
|
log::debug!("starting compute snapshot");
|
||||||
|
|
||||||
let (id, work_directory_abs_path, prev_snapshot) = this.update(cx, |this, _| {
|
let (id, work_directory_abs_path, prev_snapshot) = this.update(cx, |this, _| {
|
||||||
|
|
@ -9155,29 +9281,31 @@ async fn compute_snapshot(
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let branches_future = {
|
||||||
|
let backend = backend.clone();
|
||||||
|
async move { backend.branches().await.log_err().unwrap_or_default() }
|
||||||
|
};
|
||||||
let head_commit_future = {
|
let head_commit_future = {
|
||||||
let backend = backend.clone();
|
let backend = backend.clone();
|
||||||
async move {
|
async move {
|
||||||
Ok(match backend.head_sha().await {
|
match backend.head_sha().await {
|
||||||
Some(head_sha) => backend.show(head_sha).await.log_err(),
|
Some(head_sha) => backend.show(head_sha).await.log_err(),
|
||||||
None => None,
|
None => None,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (branches, head_commit, all_worktrees) = cx
|
let worktrees_future = {
|
||||||
.background_spawn({
|
let backend = backend.clone();
|
||||||
let backend = backend.clone();
|
async move { backend.worktrees().await.log_err().unwrap_or_default() }
|
||||||
async move {
|
};
|
||||||
futures::future::try_join3(
|
let (branches, head_commit, all_worktrees) =
|
||||||
backend.branches(),
|
futures::future::join3(branches_future, head_commit_future, worktrees_future).await;
|
||||||
head_commit_future,
|
|
||||||
backend.worktrees(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
log::debug!("fetched branches, head commit, worktrees");
|
log::debug!("fetched branches, head commit, worktrees");
|
||||||
|
|
||||||
|
let BranchesScanResult {
|
||||||
|
branches,
|
||||||
|
error: branch_list_error,
|
||||||
|
} = branches;
|
||||||
let branch = branches.iter().find(|branch| branch.is_head).cloned();
|
let branch = branches.iter().find(|branch| branch.is_head).cloned();
|
||||||
let branch_list: Arc<[Branch]> = branches.into();
|
let branch_list: Arc<[Branch]> = branches.into();
|
||||||
|
|
||||||
|
|
@ -9186,20 +9314,8 @@ async fn compute_snapshot(
|
||||||
.filter(|wt| wt.path != *work_directory_abs_path)
|
.filter(|wt| wt.path != *work_directory_abs_path)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let (remote_origin_url, remote_upstream_url) = cx
|
let remote_origin_url = backend.remote_url("origin").await;
|
||||||
.background_spawn({
|
let remote_upstream_url = backend.remote_url("upstream").await;
|
||||||
let backend = backend.clone();
|
|
||||||
async move {
|
|
||||||
Ok::<_, anyhow::Error>(
|
|
||||||
futures::future::join(
|
|
||||||
backend.remote_url("origin"),
|
|
||||||
backend.remote_url("upstream"),
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
log::debug!("fetched remotes");
|
log::debug!("fetched remotes");
|
||||||
|
|
||||||
|
|
@ -9207,6 +9323,7 @@ async fn compute_snapshot(
|
||||||
let head_changed =
|
let head_changed =
|
||||||
branch != this.snapshot.branch || head_commit != this.snapshot.head_commit;
|
branch != this.snapshot.branch || head_commit != this.snapshot.head_commit;
|
||||||
let branch_list_changed = *branch_list != *this.snapshot.branch_list;
|
let branch_list_changed = *branch_list != *this.snapshot.branch_list;
|
||||||
|
let branch_list_error_changed = branch_list_error != this.snapshot.branch_list_error;
|
||||||
let worktrees_changed = *linked_worktrees != *this.snapshot.linked_worktrees;
|
let worktrees_changed = *linked_worktrees != *this.snapshot.linked_worktrees;
|
||||||
|
|
||||||
this.snapshot = RepositorySnapshot {
|
this.snapshot = RepositorySnapshot {
|
||||||
|
|
@ -9214,6 +9331,7 @@ async fn compute_snapshot(
|
||||||
work_directory_abs_path,
|
work_directory_abs_path,
|
||||||
branch,
|
branch,
|
||||||
branch_list: branch_list.clone(),
|
branch_list: branch_list.clone(),
|
||||||
|
branch_list_error,
|
||||||
head_commit,
|
head_commit,
|
||||||
remote_origin_url,
|
remote_origin_url,
|
||||||
remote_upstream_url,
|
remote_upstream_url,
|
||||||
|
|
@ -9226,7 +9344,7 @@ async fn compute_snapshot(
|
||||||
cx.emit(RepositoryEvent::HeadChanged);
|
cx.emit(RepositoryEvent::HeadChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
if branch_list_changed {
|
if branch_list_changed || branch_list_error_changed {
|
||||||
cx.emit(RepositoryEvent::BranchListChanged);
|
cx.emit(RepositoryEvent::BranchListChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -9237,31 +9355,36 @@ async fn compute_snapshot(
|
||||||
this.snapshot.clone()
|
this.snapshot.clone()
|
||||||
});
|
});
|
||||||
|
|
||||||
let (statuses, diff_stats, stash_entries) = cx
|
let statuses_future = {
|
||||||
.background_spawn({
|
let backend = backend.clone();
|
||||||
let backend = backend.clone();
|
async move {
|
||||||
let snapshot = snapshot.clone();
|
backend
|
||||||
async move {
|
.status(&[RepoPath::from_rel_path(
|
||||||
let diff_stat_future: BoxFuture<'_, Result<status::GitDiffStat>> =
|
&RelPath::new(".".as_ref(), PathStyle::local()).unwrap(),
|
||||||
if snapshot.head_commit.is_some() {
|
)])
|
||||||
backend.diff_stat(&[])
|
|
||||||
} else {
|
|
||||||
future::ready(Ok(status::GitDiffStat {
|
|
||||||
entries: Arc::default(),
|
|
||||||
}))
|
|
||||||
.boxed()
|
|
||||||
};
|
|
||||||
futures::future::try_join3(
|
|
||||||
backend.status(&[RepoPath::from_rel_path(
|
|
||||||
&RelPath::new(".".as_ref(), PathStyle::local()).unwrap(),
|
|
||||||
)]),
|
|
||||||
diff_stat_future,
|
|
||||||
backend.stash_entries(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
|
.log_err()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let diff_stat_future = {
|
||||||
|
let snapshot = snapshot.clone();
|
||||||
|
let backend = backend.clone();
|
||||||
|
async move {
|
||||||
|
if snapshot.head_commit.is_some() {
|
||||||
|
backend.diff_stat(&[]).await.log_err().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Default::default()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.await?;
|
};
|
||||||
|
let stash_entries_future = {
|
||||||
|
let backend = backend.clone();
|
||||||
|
async move { backend.stash_entries().await.log_err().unwrap_or_default() }
|
||||||
|
};
|
||||||
|
|
||||||
|
let (statuses, diff_stats, stash_entries) =
|
||||||
|
futures::future::join3(statuses_future, diff_stat_future, stash_entries_future).await;
|
||||||
log::debug!("fetched statuses, diff stats, stash entries");
|
log::debug!("fetched statuses, diff stats, stash entries");
|
||||||
|
|
||||||
let diff_stat_map: HashMap<&RepoPath, DiffStat> =
|
let diff_stat_map: HashMap<&RepoPath, DiffStat> =
|
||||||
|
|
@ -9281,20 +9404,19 @@ async fn compute_snapshot(
|
||||||
(),
|
(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let merge_details = cx
|
let (merge_details, conflicts_changed) = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let backend = backend.clone();
|
let backend = backend.clone();
|
||||||
let mut merge_details = snapshot.merge.clone();
|
let mut merge_details = snapshot.merge.clone();
|
||||||
async move {
|
async move {
|
||||||
let conflicts_changed = merge_details.update(&backend, conflicted_paths).await?;
|
let conflicts_changed = merge_details.update(&backend, conflicted_paths).await;
|
||||||
Ok::<_, anyhow::Error>((merge_details, conflicts_changed))
|
(merge_details, conflicts_changed)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await;
|
||||||
let (merge_details, conflicts_changed) = merge_details;
|
|
||||||
log::debug!("new merge details: {merge_details:?}");
|
log::debug!("new merge details: {merge_details:?}");
|
||||||
|
|
||||||
Ok(this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
if conflicts_changed || statuses_by_path != this.snapshot.statuses_by_path {
|
if conflicts_changed || statuses_by_path != this.snapshot.statuses_by_path {
|
||||||
cx.emit(RepositoryEvent::StatusesChanged);
|
cx.emit(RepositoryEvent::StatusesChanged);
|
||||||
}
|
}
|
||||||
|
|
@ -9308,7 +9430,7 @@ async fn compute_snapshot(
|
||||||
this.snapshot.stash_entries = stash_entries;
|
this.snapshot.stash_entries = stash_entries;
|
||||||
|
|
||||||
this.snapshot.clone()
|
this.snapshot.clone()
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_from_proto(
|
fn status_from_proto(
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,18 @@ impl GitJobDebugQueue {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mark_unfinished_complete(&mut self, status: CompletedJobStatus) {
|
||||||
|
let ids = self
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.map(|job| job.id)
|
||||||
|
.chain(self.running.iter().map(|job| job.id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for id in ids {
|
||||||
|
self.mark_complete(id, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mark_complete(&mut self, id: JobId, status: CompletedJobStatus) {
|
pub fn mark_complete(&mut self, id: JobId, status: CompletedJobStatus) {
|
||||||
let (enqueued_at, started_at, description, key) =
|
let (enqueued_at, started_at, description, key) =
|
||||||
if let Some(index) = self.running.iter().position(|job| job.id == id) {
|
if let Some(index) = self.running.iter().position(|job| job.id == id) {
|
||||||
|
|
|
||||||
|
|
@ -11229,6 +11229,10 @@ async fn test_rename_work_directory(cx: &mut gpui::TestAppContext) {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tree.flush_fs_events(cx).await;
|
tree.flush_fs_events(cx).await;
|
||||||
|
project
|
||||||
|
.update(cx, |project, cx| project.git_scans_complete(cx))
|
||||||
|
.await;
|
||||||
|
cx.executor().run_until_parked();
|
||||||
|
|
||||||
repository.read_with(cx, |repository, _| {
|
repository.read_with(cx, |repository, _| {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -5467,6 +5467,241 @@ async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors real multi-buffer views (`ProjectDiagnosticsEditor`, `ProjectDiff`,
|
||||||
|
/// etc.): the workspace `Item` is a thin wrapper that holds an inner `Editor`
|
||||||
|
/// and re-emits its events.
|
||||||
|
mod multibuffer_wrapper {
|
||||||
|
use editor::{Editor, EditorEvent};
|
||||||
|
use gpui::{
|
||||||
|
App, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||||
|
ParentElement, Render, SharedString, Subscription, Window, div,
|
||||||
|
};
|
||||||
|
use workspace::item::{Item, ItemEvent, TabContentParams};
|
||||||
|
|
||||||
|
pub struct TestMultibufferWrapper {
|
||||||
|
pub editor: Entity<Editor>,
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestMultibufferWrapper {
|
||||||
|
pub fn new(editor: Entity<Editor>, cx: &mut Context<Self>) -> Self {
|
||||||
|
let _subscription = cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| {
|
||||||
|
cx.emit(event.clone());
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
editor,
|
||||||
|
_subscription,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<EditorEvent> for TestMultibufferWrapper {}
|
||||||
|
|
||||||
|
impl Focusable for TestMultibufferWrapper {
|
||||||
|
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||||
|
self.editor.read(cx).focus_handle(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TestMultibufferWrapper {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().child(self.editor.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item for TestMultibufferWrapper {
|
||||||
|
type Event = EditorEvent;
|
||||||
|
|
||||||
|
fn tab_content_text(&self, _: usize, _: &App) -> SharedString {
|
||||||
|
"wrapper".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn for_each_project_item(
|
||||||
|
&self,
|
||||||
|
cx: &App,
|
||||||
|
f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
|
||||||
|
) {
|
||||||
|
self.editor.read(cx).for_each_project_item(cx, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<project::ProjectPath> {
|
||||||
|
self.editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
|
||||||
|
Editor::to_item_events(event, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab_content(&self, params: TabContentParams, _: &Window, cx: &App) -> gpui::AnyElement {
|
||||||
|
ui::Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_autoreveal_follows_multibuffer_selection(cx: &mut gpui::TestAppContext) {
|
||||||
|
use editor::{
|
||||||
|
Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, ToOffset,
|
||||||
|
};
|
||||||
|
use language::Point;
|
||||||
|
use multibuffer_wrapper::TestMultibufferWrapper;
|
||||||
|
|
||||||
|
init_test_with_editor(cx);
|
||||||
|
cx.update(|cx| {
|
||||||
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||||
|
store.update_user_settings(cx, |settings| {
|
||||||
|
settings
|
||||||
|
.project_panel
|
||||||
|
.get_or_insert_default()
|
||||||
|
.auto_reveal_entries = Some(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let fs = FakeFs::new(cx.background_executor.clone());
|
||||||
|
fs.insert_tree(
|
||||||
|
path!("/project_root"),
|
||||||
|
json!({
|
||||||
|
"dir_1": { "file_1.py": "alpha 1\nalpha 2\nalpha 3\n" },
|
||||||
|
"dir_2": { "file_2.py": "beta 1\nbeta 2\nbeta 3\n" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
|
||||||
|
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||||
|
let workspace = window
|
||||||
|
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||||
|
.unwrap();
|
||||||
|
let cx = &mut VisualTestContext::from_window(window.into(), cx);
|
||||||
|
let panel = workspace.update_in(cx, ProjectPanel::new);
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let buffer_1 = project
|
||||||
|
.update(cx, |project, cx| {
|
||||||
|
let project_path = project
|
||||||
|
.find_project_path("project_root/dir_1/file_1.py", cx)
|
||||||
|
.unwrap();
|
||||||
|
project.open_buffer(project_path, cx)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let buffer_2 = project
|
||||||
|
.update(cx, |project, cx| {
|
||||||
|
let project_path = project
|
||||||
|
.find_project_path("project_root/dir_2/file_2.py", cx)
|
||||||
|
.unwrap();
|
||||||
|
project.open_buffer(project_path, cx)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let multi_buffer = cx.update(|_, cx| {
|
||||||
|
cx.new(|cx| {
|
||||||
|
let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite);
|
||||||
|
multi_buffer.set_excerpts_for_path(
|
||||||
|
PathKey::sorted(0),
|
||||||
|
buffer_1.clone(),
|
||||||
|
[Point::new(0, 0)..Point::new(2, 0)],
|
||||||
|
0,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
multi_buffer.set_excerpts_for_path(
|
||||||
|
PathKey::sorted(1),
|
||||||
|
buffer_2.clone(),
|
||||||
|
[Point::new(0, 0)..Point::new(2, 0)],
|
||||||
|
0,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
multi_buffer
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let inner_editor = cx.update(|window, cx| {
|
||||||
|
cx.new(|cx| {
|
||||||
|
Editor::new(
|
||||||
|
EditorMode::full(),
|
||||||
|
multi_buffer.clone(),
|
||||||
|
Some(project.clone()),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap the multibuffer editor in an `Item`, mirroring real multibuffer
|
||||||
|
// views (`ProjectDiagnosticsEditor`, `ProjectDiff`, etc.). Auto-reveal
|
||||||
|
// should follow the inner editor's active buffer.
|
||||||
|
workspace.update_in(cx, |workspace, window, cx| {
|
||||||
|
let wrapper = cx.new(|cx| TestMultibufferWrapper::new(inner_editor.clone(), cx));
|
||||||
|
workspace.add_item_to_active_pane(Box::new(wrapper), None, true, window, cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
visible_entries_as_strings(&panel, 0..20, cx),
|
||||||
|
&[
|
||||||
|
"v project_root",
|
||||||
|
" v dir_1",
|
||||||
|
" file_1.py <== selected <== marked",
|
||||||
|
" > dir_2",
|
||||||
|
],
|
||||||
|
"When a multibuffer becomes active, its first excerpt's file should be revealed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let buffer_2_offset = multi_buffer.read_with(cx, |multi_buffer, cx| {
|
||||||
|
let snapshot = multi_buffer.snapshot(cx);
|
||||||
|
let buffer_2_id = buffer_2.read(cx).remote_id();
|
||||||
|
let excerpt = snapshot
|
||||||
|
.excerpts_for_buffer(buffer_2_id)
|
||||||
|
.next()
|
||||||
|
.expect("buffer_2 excerpt must exist");
|
||||||
|
snapshot
|
||||||
|
.anchor_in_excerpt(excerpt.context.start)
|
||||||
|
.expect("excerpt anchor must resolve")
|
||||||
|
.to_offset(&snapshot)
|
||||||
|
});
|
||||||
|
|
||||||
|
inner_editor.update_in(cx, |editor, window, cx| {
|
||||||
|
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||||
|
s.select_ranges([buffer_2_offset..buffer_2_offset]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
visible_entries_as_strings(&panel, 0..20, cx),
|
||||||
|
&[
|
||||||
|
"v project_root",
|
||||||
|
" v dir_1",
|
||||||
|
" file_1.py",
|
||||||
|
" v dir_2",
|
||||||
|
" file_2.py <== selected <== marked",
|
||||||
|
],
|
||||||
|
"Moving the cursor into a different excerpt buffer should reveal that buffer's entry"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrappers re-emit inner-editor events through `to_item_events`, so a
|
||||||
|
// benign `TitleChanged` (e.g. diagnostic summary updates) ultimately
|
||||||
|
// reaches `Workspace::active_item_path_changed`. The active path should be
|
||||||
|
// recomputed from the wrapper instead of falling back to a stale selection.
|
||||||
|
inner_editor.update(cx, |_, cx| cx.emit(EditorEvent::TitleChanged));
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
visible_entries_as_strings(&panel, 0..20, cx),
|
||||||
|
&[
|
||||||
|
"v project_root",
|
||||||
|
" v dir_1",
|
||||||
|
" file_1.py",
|
||||||
|
" v dir_2",
|
||||||
|
" file_2.py <== selected <== marked",
|
||||||
|
],
|
||||||
|
"Wrapper-level title updates must not clobber the inner editor's reveal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_reveal_in_project_panel_fallback(cx: &mut gpui::TestAppContext) {
|
async fn test_reveal_in_project_panel_fallback(cx: &mut gpui::TestAppContext) {
|
||||||
init_test_with_editor(cx);
|
init_test_with_editor(cx);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import "worktree.proto";
|
||||||
|
|
||||||
message GitBranchesResponse {
|
message GitBranchesResponse {
|
||||||
repeated Branch branches = 1;
|
repeated Branch branches = 1;
|
||||||
|
optional string error = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpdateDiffBases {
|
message UpdateDiffBases {
|
||||||
|
|
@ -130,6 +131,7 @@ message UpdateRepository {
|
||||||
repeated Branch branch_list = 18;
|
repeated Branch branch_list = 18;
|
||||||
optional string repository_dir_abs_path = 19;
|
optional string repository_dir_abs_path = 19;
|
||||||
optional string common_dir_abs_path = 20;
|
optional string common_dir_abs_path = 20;
|
||||||
|
optional string branch_list_error = 21;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RemoveRepository {
|
message RemoveRepository {
|
||||||
|
|
|
||||||
|
|
@ -926,6 +926,7 @@ pub fn split_repository_update(
|
||||||
let mut updated_statuses_iter = mem::take(&mut update.updated_statuses).into_iter().fuse();
|
let mut updated_statuses_iter = mem::take(&mut update.updated_statuses).into_iter().fuse();
|
||||||
let mut removed_statuses_iter = mem::take(&mut update.removed_statuses).into_iter().fuse();
|
let mut removed_statuses_iter = mem::take(&mut update.removed_statuses).into_iter().fuse();
|
||||||
let branch_list = mem::take(&mut update.branch_list);
|
let branch_list = mem::take(&mut update.branch_list);
|
||||||
|
let branch_list_error = update.branch_list_error.take();
|
||||||
std::iter::from_fn({
|
std::iter::from_fn({
|
||||||
let update = update.clone();
|
let update = update.clone();
|
||||||
move || {
|
move || {
|
||||||
|
|
@ -944,6 +945,7 @@ pub fn split_repository_update(
|
||||||
updated_statuses,
|
updated_statuses,
|
||||||
removed_statuses,
|
removed_statuses,
|
||||||
branch_list: Vec::new(),
|
branch_list: Vec::new(),
|
||||||
|
branch_list_error: None,
|
||||||
is_last_update: false,
|
is_last_update: false,
|
||||||
..update.clone()
|
..update.clone()
|
||||||
})
|
})
|
||||||
|
|
@ -953,6 +955,7 @@ pub fn split_repository_update(
|
||||||
updated_statuses: Vec::new(),
|
updated_statuses: Vec::new(),
|
||||||
removed_statuses: Vec::new(),
|
removed_statuses: Vec::new(),
|
||||||
branch_list,
|
branch_list,
|
||||||
|
branch_list_error,
|
||||||
is_last_update: true,
|
is_last_update: true,
|
||||||
..update
|
..update
|
||||||
}])
|
}])
|
||||||
|
|
@ -1023,6 +1026,7 @@ mod tests {
|
||||||
ref_name: "refs/heads/main".into(),
|
ref_name: "refs/heads/main".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}],
|
}],
|
||||||
|
branch_list_error: Some("partial branch scan".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1033,6 +1037,12 @@ mod tests {
|
||||||
assert!(chunks[1].branch_list.is_empty());
|
assert!(chunks[1].branch_list.is_empty());
|
||||||
assert_eq!(chunks[2].branch_list.len(), 1);
|
assert_eq!(chunks[2].branch_list.len(), 1);
|
||||||
assert_eq!(chunks[2].branch_list[0].ref_name, "refs/heads/main");
|
assert_eq!(chunks[2].branch_list[0].ref_name, "refs/heads/main");
|
||||||
|
assert_eq!(chunks[0].branch_list_error, None);
|
||||||
|
assert_eq!(chunks[1].branch_list_error, None);
|
||||||
|
assert_eq!(
|
||||||
|
chunks[2].branch_list_error.as_deref(),
|
||||||
|
Some("partial branch scan")
|
||||||
|
);
|
||||||
assert!(!chunks[0].is_last_update);
|
assert!(!chunks[0].is_last_update);
|
||||||
assert!(!chunks[1].is_last_update);
|
assert!(!chunks[1].is_last_update);
|
||||||
assert!(chunks[2].is_last_update);
|
assert!(chunks[2].is_last_update);
|
||||||
|
|
|
||||||
|
|
@ -2121,7 +2121,8 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
|
||||||
.update(cx, |repository, _| repository.branches())
|
.update(cx, |repository, _| repository.branches())
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
.branches;
|
||||||
|
|
||||||
let new_branch = branches[2];
|
let new_branch = branches[2];
|
||||||
|
|
||||||
|
|
@ -2386,7 +2387,10 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu
|
||||||
.run(ToolInput::resolved(input), event_stream.clone(), cx)
|
.run(ToolInput::resolved(input), event_stream.clone(), cx)
|
||||||
});
|
});
|
||||||
let output = exists_result.await.unwrap();
|
let output = exists_result.await.unwrap();
|
||||||
assert_eq!(output, LanguageModelToolResultContent::Text("B".into()));
|
assert_eq!(
|
||||||
|
output,
|
||||||
|
LanguageModelToolResultContent::Text(" 1\tB".into())
|
||||||
|
);
|
||||||
|
|
||||||
let input = ReadFileToolInput {
|
let input = ReadFileToolInput {
|
||||||
path: "project/c.txt".into(),
|
path: "project/c.txt".into(),
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,14 @@ impl TestScheduler {
|
||||||
.map(|seed| seed.parse().unwrap())
|
.map(|seed| seed.parse().unwrap())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let interactive = !std::env::var("SCHEDULER_NONINTERACTIVE").is_ok();
|
||||||
|
|
||||||
(seed..seed + num_iterations as u64)
|
(seed..seed + num_iterations as u64)
|
||||||
.map(|seed| {
|
.map(|seed| {
|
||||||
let mut unwind_safe_f = AssertUnwindSafe(&mut f);
|
let mut unwind_safe_f = AssertUnwindSafe(&mut f);
|
||||||
eprintln!("Running seed: {seed}");
|
if interactive {
|
||||||
|
eprintln!("Running seed: {seed}");
|
||||||
|
}
|
||||||
match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) {
|
match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
|
||||||
|
|
@ -679,6 +679,10 @@ impl Item for ProjectSearchView {
|
||||||
self.results_editor.for_each_project_item(cx, f)
|
self.results_editor.for_each_project_item(cx, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
self.results_editor.read(cx).active_project_path(cx)
|
||||||
|
}
|
||||||
|
|
||||||
fn can_save(&self, _: &App) -> bool {
|
fn can_save(&self, _: &App) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -524,15 +524,42 @@ fn spawn_posix_spawn(
|
||||||
|
|
||||||
fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> {
|
fn create_pipe() -> io::Result<(libc::c_int, libc::c_int)> {
|
||||||
let mut fds: [libc::c_int; 2] = [0; 2];
|
let mut fds: [libc::c_int; 2] = [0; 2];
|
||||||
let result = unsafe { libc::pipe(fds.as_mut_ptr()) };
|
unsafe {
|
||||||
if result == -1 {
|
let result = libc::pipe(fds.as_mut_ptr());
|
||||||
return Err(io::Error::last_os_error());
|
if result == -1 {
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set close-on-exec on both ends of the pipe.
|
||||||
|
//
|
||||||
|
// Without this, unrelated spawns elsewhere in the process (e.g.
|
||||||
|
// `smol::process` or `async_process`, which on Apple platforms use
|
||||||
|
// `posix_spawn` *without* `POSIX_SPAWN_CLOEXEC_DEFAULT`) would inherit
|
||||||
|
// these file descriptors and keep the pipes open even after we drop our
|
||||||
|
// side.
|
||||||
|
for &fd in &fds {
|
||||||
|
let result = libc::ioctl(fd, libc::FIOCLEX);
|
||||||
|
if result == -1 {
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
libc::close(fds[0]);
|
||||||
|
libc::close(fds[1]);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((fds[0], fds[1]))
|
||||||
}
|
}
|
||||||
Ok((fds[0], fds[1]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_dev_null(flags: libc::c_int) -> io::Result<libc::c_int> {
|
fn open_dev_null(flags: libc::c_int) -> io::Result<libc::c_int> {
|
||||||
let fd = unsafe { libc::open(c"/dev/null".as_ptr() as *const libc::c_char, flags) };
|
// Set close-on-exec for this pipe, for the same reason as in `create_pipe`.
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::open(
|
||||||
|
c"/dev/null".as_ptr() as *const libc::c_char,
|
||||||
|
flags | libc::O_CLOEXEC,
|
||||||
|
)
|
||||||
|
};
|
||||||
if fd == -1 {
|
if fd == -1 {
|
||||||
return Err(io::Error::last_os_error());
|
return Err(io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
|
@ -561,6 +588,49 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use futures_lite::AsyncWriteExt;
|
use futures_lite::AsyncWriteExt;
|
||||||
|
|
||||||
|
// Verifies that pipes returned by `create_pipe` aren't visible to unrelated
|
||||||
|
// child processes spawned via `std::process::Command`. On macOS, `std`
|
||||||
|
// uses `posix_spawn` without `POSIX_SPAWN_CLOEXEC_DEFAULT`, so any
|
||||||
|
// non-CLOEXEC fd in the parent leaks into the child. Without
|
||||||
|
// `FD_CLOEXEC` on our pipe fds, an unrelated spawn (a terminal, the crash
|
||||||
|
// handler, etc.) running concurrently with a piped git child would hold
|
||||||
|
// git's stdin write end open and deadlock the git child on `read()`.
|
||||||
|
#[test]
|
||||||
|
fn test_create_pipe_not_inherited_by_unrelated_spawn() {
|
||||||
|
let (read_fd, write_fd) = create_pipe().expect("create_pipe failed");
|
||||||
|
|
||||||
|
// Probe with the exact fds returned by `create_pipe` (no dup), since
|
||||||
|
// duping with `F_DUPFD` would lose CLOEXEC and `F_DUPFD_CLOEXEC` would
|
||||||
|
// unconditionally set it, either of which would defeat the test.
|
||||||
|
#[allow(clippy::disallowed_methods)]
|
||||||
|
let output = std::process::Command::new("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!(
|
||||||
|
"for fd in {read_fd} {write_fd}; do \
|
||||||
|
if [ -e /dev/fd/$fd ]; then \
|
||||||
|
echo $fd WAS INHERITED; \
|
||||||
|
else \
|
||||||
|
echo $fd WAS NOT INHERITED; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
echo DONE"
|
||||||
|
))
|
||||||
|
.output()
|
||||||
|
.expect("failed to spawn sh");
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
libc::close(read_fd);
|
||||||
|
libc::close(write_fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stdout,
|
||||||
|
format!("{read_fd} WAS NOT INHERITED\n{write_fd} WAS NOT INHERITED\nDONE\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_spawn_echo() {
|
fn test_spawn_echo() {
|
||||||
smol::block_on(async {
|
smol::block_on(async {
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,24 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
|
||||||
fn buffer_kind(&self, _cx: &App) -> ItemBufferKind {
|
fn buffer_kind(&self, _cx: &App) -> ItemBufferKind {
|
||||||
ItemBufferKind::None
|
ItemBufferKind::None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the project path that should be treated as active for this item.
|
||||||
|
///
|
||||||
|
/// Singleton items use their only project item by default. Items backed by
|
||||||
|
/// multiple buffers should override this to return the path for the buffer
|
||||||
|
/// under the primary cursor or otherwise selected sub-item.
|
||||||
|
fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
|
if self.buffer_kind(cx) != ItemBufferKind::Singleton {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = None;
|
||||||
|
self.for_each_project_item(cx, &mut |_, item| {
|
||||||
|
result = item.project_path(cx);
|
||||||
|
});
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
fn set_nav_history(&mut self, _: ItemNavHistory, _window: &mut Window, _: &mut Context<Self>) {}
|
fn set_nav_history(&mut self, _: ItemNavHistory, _window: &mut Window, _: &mut Context<Self>) {}
|
||||||
|
|
||||||
fn can_split(&self) -> bool {
|
fn can_split(&self) -> bool {
|
||||||
|
|
@ -646,14 +664,7 @@ impl<T: Item> ItemHandle for Entity<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_path(&self, cx: &App) -> Option<ProjectPath> {
|
fn project_path(&self, cx: &App) -> Option<ProjectPath> {
|
||||||
let this = self.read(cx);
|
<T as Item>::active_project_path(self.read(cx), cx)
|
||||||
let mut result = None;
|
|
||||||
if this.buffer_kind(cx) == ItemBufferKind::Singleton {
|
|
||||||
this.for_each_project_item(cx, &mut |_, item| {
|
|
||||||
result = item.project_path(cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_settings<'a>(&self, cx: &'a App) -> &'a WorkspaceSettings {
|
fn workspace_settings<'a>(&self, cx: &'a App) -> &'a WorkspaceSettings {
|
||||||
|
|
@ -910,6 +921,16 @@ impl<T: Item> ItemHandle for Entity<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ItemEvent::UpdateBreadcrumbs => {
|
||||||
|
if &pane == workspace.active_pane()
|
||||||
|
&& pane.read(cx).active_item().is_some_and(|active_item| {
|
||||||
|
active_item.item_id() == item.item_id()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
workspace.active_item_path_changed(false, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ItemEvent::Edit => {
|
ItemEvent::Edit => {
|
||||||
let autosave = item.workspace_settings(cx).autosave;
|
let autosave = item.workspace_settings(cx).autosave;
|
||||||
|
|
||||||
|
|
@ -932,8 +953,6 @@ impl<T: Item> ItemHandle for Entity<T> {
|
||||||
}
|
}
|
||||||
pane.update(cx, |pane, cx| pane.handle_item_edit(item.item_id(), cx));
|
pane.update(cx, |pane, cx| pane.handle_item_edit(item.item_id(), cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => {}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -5958,7 +5958,7 @@ impl Workspace {
|
||||||
self.follower_states.contains_key(&id.into())
|
self.follower_states.contains_key(&id.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_item_path_changed(
|
pub(crate) fn active_item_path_changed(
|
||||||
&mut self,
|
&mut self,
|
||||||
focus_changed: bool,
|
focus_changed: bool,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
|
|
|
||||||
|
|
@ -4446,6 +4446,9 @@ impl BackgroundScanner {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dot_git_abs_paths.contains(&dot_git_abs_path) {
|
if !dot_git_abs_paths.contains(&dot_git_abs_path) {
|
||||||
|
log::debug!(
|
||||||
|
"detected update within git repo at {dot_git_abs_path:?}: {abs_path:?}"
|
||||||
|
);
|
||||||
dot_git_abs_paths.push(dot_git_abs_path);
|
dot_git_abs_paths.push(dot_git_abs_path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4526,7 +4529,7 @@ impl BackgroundScanner {
|
||||||
.is_some_and(|entry| entry.kind == EntryKind::Dir)
|
.is_some_and(|entry| entry.kind == EntryKind::Dir)
|
||||||
});
|
});
|
||||||
if !parent_dir_is_loaded {
|
if !parent_dir_is_loaded {
|
||||||
log::debug!("ignoring event {relative_path:?} within unloaded directory");
|
log::debug!("filtering event {relative_path:?} within unloaded directory");
|
||||||
skip_ix(&mut ranges_to_drop, ix);
|
skip_ix(&mut ranges_to_drop, ix);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -4567,13 +4570,15 @@ impl BackgroundScanner {
|
||||||
self.state.lock().await.snapshot.scan_id += 1;
|
self.state.lock().await.snapshot.scan_id += 1;
|
||||||
|
|
||||||
let (scan_job_tx, scan_job_rx) = async_channel::unbounded();
|
let (scan_job_tx, scan_job_rx) = async_channel::unbounded();
|
||||||
log::debug!(
|
if !relative_paths.is_empty() {
|
||||||
"received fs events {:?}",
|
log::debug!(
|
||||||
relative_paths
|
"will update project paths {:?}",
|
||||||
.iter()
|
relative_paths
|
||||||
.map(|event_root| &event_root.path)
|
.iter()
|
||||||
.collect::<Vec<_>>()
|
.map(|event_root| &event_root.path)
|
||||||
);
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
self.reload_entries_for_paths(
|
self.reload_entries_for_paths(
|
||||||
&root_path,
|
&root_path,
|
||||||
&root_canonical_path,
|
&root_canonical_path,
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ use language_tools::lsp_log_view::LspLogToolbarItemView;
|
||||||
use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
|
use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
|
||||||
use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType};
|
use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType};
|
||||||
use migrator::migrate_keymap;
|
use migrator::migrate_keymap;
|
||||||
use onboarding::DOCS_URL;
|
|
||||||
use onboarding::multibuffer_hint::MultibufferHint;
|
use onboarding::multibuffer_hint::MultibufferHint;
|
||||||
pub use open_listener::*;
|
pub use open_listener::*;
|
||||||
use outline_panel::OutlinePanel;
|
use outline_panel::OutlinePanel;
|
||||||
|
|
@ -100,9 +99,12 @@ use workspace::{
|
||||||
use workspace::{Pane, notifications::DetachAndPromptErr};
|
use workspace::{Pane, notifications::DetachAndPromptErr};
|
||||||
use zed_actions::{
|
use zed_actions::{
|
||||||
About, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile,
|
About, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile,
|
||||||
OpenZedUrl, Quit,
|
OpenStatusPage, OpenZedUrl, Quit,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DOCS_URL: &str = "https://zed.dev/docs/";
|
||||||
|
const STATUS_URL: &str = "https://status.zed.dev";
|
||||||
|
|
||||||
pub struct CrashHandler(pub Arc<crashes::Client>);
|
pub struct CrashHandler(pub Arc<crashes::Client>);
|
||||||
|
|
||||||
impl gpui::Global for CrashHandler {}
|
impl gpui::Global for CrashHandler {}
|
||||||
|
|
@ -860,6 +862,7 @@ fn register_actions(
|
||||||
) {
|
) {
|
||||||
workspace
|
workspace
|
||||||
.register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL))
|
.register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL))
|
||||||
|
.register_action(|_, _: &OpenStatusPage, _, cx| cx.open_url(STATUS_URL))
|
||||||
.register_action(
|
.register_action(
|
||||||
|workspace: &mut Workspace,
|
|workspace: &mut Workspace,
|
||||||
_: &input_latency_ui::DumpInputLatencyHistogram,
|
_: &input_latency_ui::DumpInputLatencyHistogram,
|
||||||
|
|
@ -4175,7 +4178,7 @@ mod tests {
|
||||||
let (editor_1, buffer) = workspace.update_in(cx, |_, window, cx| {
|
let (editor_1, buffer) = workspace.update_in(cx, |_, window, cx| {
|
||||||
pane_1.update(cx, |pane_1, cx| {
|
pane_1.update(cx, |pane_1, cx| {
|
||||||
let editor = pane_1.active_item().unwrap().downcast::<Editor>().unwrap();
|
let editor = pane_1.active_item().unwrap().downcast::<Editor>().unwrap();
|
||||||
assert_eq!(editor.project_path(cx), Some(file1.clone()));
|
assert_eq!(editor.read(cx).active_project_path(cx), Some(file1.clone()));
|
||||||
let buffer = editor.update(cx, |editor, cx| {
|
let buffer = editor.update(cx, |editor, cx| {
|
||||||
editor.insert("dirt", window, cx);
|
editor.insert("dirt", window, cx);
|
||||||
editor.buffer().downgrade()
|
editor.buffer().downgrade()
|
||||||
|
|
@ -4731,7 +4734,7 @@ mod tests {
|
||||||
let scroll_position = editor_ref.scroll_position(cx);
|
let scroll_position = editor_ref.scroll_position(cx);
|
||||||
|
|
||||||
(
|
(
|
||||||
editor_ref.project_path(cx).unwrap(),
|
editor_ref.active_project_path(cx).unwrap(),
|
||||||
selections[0].start,
|
selections[0].start,
|
||||||
scroll_position.y,
|
scroll_position.y,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ actions!(
|
||||||
OpenDocs,
|
OpenDocs,
|
||||||
/// Views open source licenses.
|
/// Views open source licenses.
|
||||||
OpenLicenses,
|
OpenLicenses,
|
||||||
|
/// Opens the Zed status page.
|
||||||
|
OpenStatusPage,
|
||||||
/// Opens the telemetry log.
|
/// Opens the telemetry log.
|
||||||
OpenTelemetryLog,
|
OpenTelemetryLog,
|
||||||
/// Opens the performance profiler.
|
/// Opens the performance profiler.
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ my-extension/
|
||||||
rust.json
|
rust.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebAssembly
|
## Rust and WebAssembly
|
||||||
|
|
||||||
|
> Please note that most extensions will work properly without any Rust code present. In particular, only language server, context server and debugger extensions require the presence custom Rust in order to function properly.
|
||||||
|
|
||||||
Procedural parts of extensions are written in Rust and compiled to WebAssembly. To develop an extension that includes custom code, include a `Cargo.toml` like this:
|
Procedural parts of extensions are written in Rust and compiled to WebAssembly. To develop an extension that includes custom code, include a `Cargo.toml` like this:
|
||||||
|
|
||||||
|
|
@ -101,7 +103,11 @@ impl zed::Extension for MyExtension {
|
||||||
zed::register_extension!(MyExtension);
|
zed::register_extension!(MyExtension);
|
||||||
```
|
```
|
||||||
|
|
||||||
> `stdout`/`stderr` is forwarded directly to the Zed process. In order to see `println!`/`dbg!` output from your extension, you can start Zed in your terminal with a `--foreground` flag.
|
> Since your extension will be compiled to WebAssembly, some Rust features might not work like you would expect them to. For example, `cfg` - directives will not work and `std::env::var` will also not yield the expected results. Instead, use the [`zed_extension_api::current_platform`](https://docs.rs/zed_extension_api/latest/zed_extension_api/fn.current_platform.html) method to get information about the current environment and familiarize yourself with the [`Worktree` struct and its methods](https://docs.rs/zed_extension_api/latest/zed_extension_api/struct.Worktree.html) for reading environment variables and finding binaries in the users `PATH`.
|
||||||
|
|
||||||
|
### Debugging your Rust extension
|
||||||
|
|
||||||
|
`stdout`/`stderr` is forwarded directly to the Zed process. In order to see `println!`/`dbg!` output from your extension, you can start Zed in your terminal with a `--foreground` flag.
|
||||||
|
|
||||||
## Forking and cloning the repo
|
## Forking and cloning the repo
|
||||||
|
|
||||||
|
|
@ -153,6 +159,10 @@ Furthermore, please make sure that your extension fulfills the following precond
|
||||||
|
|
||||||
- Extension IDs and names must not contain the words `zed`, `Zed` or `extension`, since they are all Zed extensions.
|
- Extension IDs and names must not contain the words `zed`, `Zed` or `extension`, since they are all Zed extensions.
|
||||||
- Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule are extension that provide support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced.
|
- Your extension ID should provide some information on what your extension tries to accomplish. E.g. for themes, it should be suffixed with `-theme`, snippet extensions should be suffixed with `-snippets` and so on. An exception to that rule are extension that provide support for languages or popular tooling that people would expect to find under that ID. You can take a look at the list of [existing extensions](https://github.com/zed-industries/extensions/blob/main/extensions.toml) to get a grasp on how this usually is enforced.
|
||||||
|
- Your extension must only include the resources it requires to function and nothing else.
|
||||||
|
- See the [directory structure of a Zed extension](#directory-structure-of-a-zed-extension) and the [Rust and WebAssembly](#rust-and-webassembly) sections for more information.
|
||||||
|
- Extensions must in no way attempt to read nor modify the environment outside of the environment designated to them by Zed. Should they need to read the environment, they should use methods as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/) and may fall back to appropriate methods from the Rust standard library. Should they need changes to the environment, they must instead ask the user to perform these for them using an appropriate method within the context (e.g. provide information for doing so using the `ContextServerConfiguration` for context servers).
|
||||||
|
- Please make sure to have read the [Rust and WebAssembly section above](#rust-and-webassembly) for more information and help regarding this topic.
|
||||||
- Extensions should provide something that is not yet available in the marketplace as opposed to fixing something that could be resolved within an existing extension. For example, if you find that an existing extension's support for a language server is not functioning properly, first try contributing a fix to the existing extension as opposed to submitting a new extension immediately.
|
- Extensions should provide something that is not yet available in the marketplace as opposed to fixing something that could be resolved within an existing extension. For example, if you find that an existing extension's support for a language server is not functioning properly, first try contributing a fix to the existing extension as opposed to submitting a new extension immediately.
|
||||||
- If you receive no response or reaction within the upstream repository within a reasonable amount of time, feel free to submit a pull request that aims to fix said issue. Please ensure that you provide your previous efforts within the pull request to the extensions repository for adding your extension. Zed maintainers will then decide on how to proceed on a case by case basis.
|
- If you receive no response or reaction within the upstream repository within a reasonable amount of time, feel free to submit a pull request that aims to fix said issue. Please ensure that you provide your previous efforts within the pull request to the extensions repository for adding your extension. Zed maintainers will then decide on how to proceed on a case by case basis.
|
||||||
- Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the users environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/).
|
- Extensions that intend to provide a language, debugger or MCP server must not ship the language server as part of the extension. Instead, the extension should either download the language server or check for the availability of the language server in the users environment using the APIs as provided by the [Zed Rust Extension API](https://docs.rs/zed_extension_api/latest/zed_extension_api/).
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ TBD: Centered layout related settings
|
||||||
// Minimap related settings
|
// Minimap related settings
|
||||||
"minimap": {
|
"minimap": {
|
||||||
"show": "never", // When to show (auto, always, never)
|
"show": "never", // When to show (auto, always, never)
|
||||||
"display_in": "active_editor", // Where to show (active_editor, all_editor)
|
"display_in": "active_editor", // Where to show (active_editor, all_editors)
|
||||||
"thumb": "always", // When to show thumb (always, hover)
|
"thumb": "always", // When to show thumb (always, hover)
|
||||||
"thumb_border": "left_open", // Thumb border (left_open, right_open, full, none)
|
"thumb_border": "left_open", // Thumb border (left_open, right_open, full, none)
|
||||||
"max_width_columns": 80, // Maximum width of minimap
|
"max_width_columns": 80, // Maximum width of minimap
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Comment on newly opened issues that might be duplicates of an existing issue.
|
Comment on newly opened issues with possible duplicates and triage hints.
|
||||||
|
|
||||||
This script is run by a GitHub Actions workflow when a new bug or crash report
|
This script is run by a GitHub Actions workflow when a new issue is opened. It:
|
||||||
is opened. It:
|
1. Checks eligibility (bug/crash type or untyped, non-staff author)
|
||||||
1. Checks eligibility (must be bug/crash type, non-staff author)
|
|
||||||
2. Detects relevant areas using Claude + the area label taxonomy
|
2. Detects relevant areas using Claude + the area label taxonomy
|
||||||
3. Parses known "duplicate magnets" from tracking issue #46355
|
3. Parses known "duplicate magnets" from tracking issue #46355
|
||||||
4. Searches for similar recent issues by title keywords, area labels, and error patterns
|
4. Searches for similar issues — open (last 60 days) and recently closed (last 30 days)
|
||||||
5. Asks Claude to analyze potential duplicates (magnets + search results)
|
5. Asks Claude to sort open candidates into likely and possible duplicates, and
|
||||||
6. Posts a comment on the issue if high-confidence duplicates are found
|
surface recently closed issues that may be useful triage context
|
||||||
|
6. Posts a comment if anything is found: a user-facing duplicate alert for likely
|
||||||
|
duplicates, and/or a collapsed triager-facing section for possible duplicates
|
||||||
|
and recently closed related issues
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
requests (pip install requests)
|
requests (pip install requests)
|
||||||
|
|
@ -28,6 +30,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -48,6 +51,9 @@ STOPWORDS = {
|
||||||
"the", "this", "when", "while", "with", "won't", "work", "working", "zed",
|
"the", "this", "when", "while", "with", "won't", "work", "working", "zed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# HTTP statuses we'll retry on for GET requests
|
||||||
|
TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504}
|
||||||
|
|
||||||
|
|
||||||
def log(message):
|
def log(message):
|
||||||
"""Print to stderr so it doesn't interfere with JSON output on stdout."""
|
"""Print to stderr so it doesn't interfere with JSON output on stdout."""
|
||||||
|
|
@ -55,11 +61,22 @@ def log(message):
|
||||||
|
|
||||||
|
|
||||||
def github_api_get(path, params=None):
|
def github_api_get(path, params=None):
|
||||||
"""Fetch JSON from the GitHub API. Raises on non-2xx status."""
|
"""Fetch JSON from the GitHub API, retrying transient failures. Raises on non-2xx status."""
|
||||||
url = f"{GITHUB_API}/{path.lstrip('/')}"
|
url = f"{GITHUB_API}/{path.lstrip('/')}"
|
||||||
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
|
for attempt in range(3):
|
||||||
response.raise_for_status()
|
try:
|
||||||
return response.json()
|
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or (
|
||||||
|
isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES
|
||||||
|
)
|
||||||
|
if not transient or attempt == 2:
|
||||||
|
raise
|
||||||
|
wait = 2 ** attempt
|
||||||
|
log(f" Transient GitHub API error ({e}); retrying in {wait}s")
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
|
||||||
def github_search_issues(query, per_page=15):
|
def github_search_issues(query, per_page=15):
|
||||||
|
|
@ -86,17 +103,23 @@ def post_comment(issue_number: int, body):
|
||||||
log(f" Posted comment on #{issue_number}")
|
log(f" Posted comment on #{issue_number}")
|
||||||
|
|
||||||
|
|
||||||
def build_duplicate_comment(matches):
|
def build_comment(likely_duplicates, possible_duplicates, related_closed_issues):
|
||||||
"""Build the comment body for potential duplicates."""
|
"""Compose the full comment body. Returns empty string if there's nothing to post.
|
||||||
match_list = "\n".join(f"- #{m['number']}" for m in matches)
|
|
||||||
explanations = "\n\n".join(
|
|
||||||
f"**#{m['number']}:** {m['explanation']}\n\n**Shared root cause:** {m['shared_root_cause']}"
|
|
||||||
if m.get('shared_root_cause')
|
|
||||||
else f"**#{m['number']}:** {m['explanation']}"
|
|
||||||
for m in matches
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"""This issue appears to be a duplicate of:
|
The comment has two sections, each optional:
|
||||||
|
- User-facing duplicate alert, rendered when likely_duplicates is non-empty.
|
||||||
|
- Collapsed triage context, rendered when there are possible duplicates or
|
||||||
|
related closed issues to surface for triagers.
|
||||||
|
"""
|
||||||
|
sections = []
|
||||||
|
|
||||||
|
if likely_duplicates:
|
||||||
|
match_list = "\n".join(f"- #{m['number']}" for m in likely_duplicates)
|
||||||
|
explanations = "\n\n".join(
|
||||||
|
f"**#{m['number']}:** {m['explanation']}\n\n**Shared root cause:** {m['shared_root_cause']}"
|
||||||
|
for m in likely_duplicates
|
||||||
|
)
|
||||||
|
sections.append(f"""This issue appears to be a duplicate of:
|
||||||
|
|
||||||
{match_list}
|
{match_list}
|
||||||
|
|
||||||
|
|
@ -111,10 +134,36 @@ No action needed. A maintainer will review this shortly.
|
||||||
|
|
||||||
{explanations}
|
{explanations}
|
||||||
|
|
||||||
</details>
|
</details>""")
|
||||||
|
|
||||||
---
|
if possible_duplicates or related_closed_issues:
|
||||||
<sub>This is an automated analysis and might be incorrect.</sub>"""
|
parts = []
|
||||||
|
if possible_duplicates:
|
||||||
|
lines = [
|
||||||
|
f"- #{m['number']} — {m['explanation']}\n"
|
||||||
|
f" - Possible shared root cause: {m['shared_root_cause']}"
|
||||||
|
for m in possible_duplicates
|
||||||
|
]
|
||||||
|
parts.append("**Possibly related open issues:**\n\n" + "\n".join(lines))
|
||||||
|
if related_closed_issues:
|
||||||
|
lines = [
|
||||||
|
f"- #{m['number']} (closed as {m['state_reason']}) — {m['explanation']}"
|
||||||
|
for m in related_closed_issues
|
||||||
|
]
|
||||||
|
parts.append("**Recently closed, possibly related:**\n\n" + "\n".join(lines))
|
||||||
|
body = "\n\n".join(parts)
|
||||||
|
sections.append(f"""<details>
|
||||||
|
<summary>Additional recent context for triagers</summary>
|
||||||
|
|
||||||
|
{body}
|
||||||
|
|
||||||
|
</details>""")
|
||||||
|
|
||||||
|
if not sections:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
sections.append("---\n<sub>This is an automated analysis and might be incorrect.</sub>")
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
def call_claude(api_key, system, user_content, max_tokens=1024):
|
def call_claude(api_key, system, user_content, max_tokens=1024):
|
||||||
|
|
@ -165,8 +214,8 @@ def fetch_issue(issue_number: int):
|
||||||
|
|
||||||
def should_skip(issue):
|
def should_skip(issue):
|
||||||
"""Check if issue should be skipped in duplicate detection process."""
|
"""Check if issue should be skipped in duplicate detection process."""
|
||||||
if issue["type"] not in ["Bug", "Crash"]:
|
if issue["type"] and issue["type"] not in ["Bug", "Crash"]:
|
||||||
log(f" Skipping: issue type '{issue['type']}' is not a bug/crash report")
|
log(f" Skipping: issue type '{issue['type']}' is not blank and not a bug/crash report")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if issue["author"] and check_team_membership(REPO_OWNER, STAFF_TEAM_SLUG, issue["author"]):
|
if issue["author"] and check_team_membership(REPO_OWNER, STAFF_TEAM_SLUG, issue["author"]):
|
||||||
|
|
@ -218,23 +267,32 @@ def format_taxonomy_for_claude(area_labels):
|
||||||
return "\n".join(sorted(lines))
|
return "\n".join(sorted(lines))
|
||||||
|
|
||||||
|
|
||||||
def detect_areas(anthropic_key, issue, taxonomy):
|
def detect_areas(anthropic_key, issue, area_labels):
|
||||||
"""Use Claude to detect relevant areas for the issue."""
|
"""Use Claude to detect which area labels apply to the issue.
|
||||||
|
|
||||||
|
Claude may ignore the format instruction or hallucinate names, so the response
|
||||||
|
is validated against the canonical set of area labels.
|
||||||
|
"""
|
||||||
log("Detecting areas with Claude")
|
log("Detecting areas with Claude")
|
||||||
|
|
||||||
|
taxonomy = format_taxonomy_for_claude(area_labels)
|
||||||
|
valid_areas = {label["name"] for label in area_labels}
|
||||||
|
|
||||||
system_prompt = """You analyze GitHub issues to identify which area labels apply.
|
system_prompt = """You analyze GitHub issues to identify which area labels apply.
|
||||||
|
|
||||||
Given an issue and a taxonomy of areas, output ONLY a comma-separated list of matching area names.
|
Respond with ONLY a comma-separated list of matching area names. No prose, no explanation,
|
||||||
|
no markdown, no preamble — just the names.
|
||||||
|
|
||||||
- Output at most 3 areas, ranked by relevance
|
- Output at most 3 areas, ranked by relevance
|
||||||
- Use exact area names from the taxonomy
|
- Use exact area names from the taxonomy
|
||||||
- If no areas clearly match, output: none
|
- If no areas clearly match, respond with: none
|
||||||
- For languages/*, tooling/*, or parity/*, use the specific sub-label (e.g., "languages/rust",
|
- For languages/*, tooling/*, or parity/*, use the specific sub-label (e.g., "languages/rust",
|
||||||
tooling/eslint, parity/vscode)
|
tooling/eslint, parity/vscode)
|
||||||
|
|
||||||
Example outputs:
|
Examples of valid responses (each line is a complete response on its own):
|
||||||
- "editor, parity/vim"
|
editor, parity/vim
|
||||||
- "ai, ai/agent panel"
|
ai, ai/agent panel
|
||||||
- "none"
|
none
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user_content = f"""## Area Taxonomy
|
user_content = f"""## Area Taxonomy
|
||||||
|
|
@ -251,7 +309,14 @@ Example outputs:
|
||||||
|
|
||||||
if response.lower() == "none":
|
if response.lower() == "none":
|
||||||
return []
|
return []
|
||||||
return [area.strip() for area in response.split(",")]
|
|
||||||
|
valid, dropped = [], []
|
||||||
|
for area in response.split(","):
|
||||||
|
area = area.strip()
|
||||||
|
(valid if area in valid_areas else dropped).append(area)
|
||||||
|
if dropped:
|
||||||
|
log(f" Dropped {len(dropped)} unknown area(s) from Claude response: {dropped}")
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
def parse_duplicate_magnets():
|
def parse_duplicate_magnets():
|
||||||
|
|
@ -344,53 +409,76 @@ def filter_magnets_by_areas(magnets, detected_areas):
|
||||||
return list(filter(matches, magnets))
|
return list(filter(matches, magnets))
|
||||||
|
|
||||||
|
|
||||||
def search_for_similar_issues(issue, detected_areas, max_searches=6):
|
def search_for_similar_issues(issue, detected_areas, max_searches_per_state=6):
|
||||||
"""Search for similar issues that might be duplicates.
|
"""Search for similar issues — both open and recently closed.
|
||||||
|
|
||||||
Searches by title keywords, area labels (last 60 days), and error patterns.
|
Runs two passes:
|
||||||
max_searches caps the total number of queries to keep token usage and context size under control.
|
- Open issues: title keywords / error pattern unrestricted, area searches last 60 days.
|
||||||
|
- Closed issues: closed within the last 30 days (across all query types).
|
||||||
|
|
||||||
|
max_searches_per_state caps queries per state to keep token usage and context size bounded.
|
||||||
"""
|
"""
|
||||||
log("Searching for similar issues")
|
log("Searching for similar issues")
|
||||||
|
|
||||||
sixty_days_ago = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d")
|
sixty_days_ago = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d")
|
||||||
base_query = f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open"
|
thirty_days_ago = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||||
seen_issues = {}
|
|
||||||
queries = []
|
|
||||||
|
|
||||||
title_keywords = [word for word in issue["title"].split() if word.lower() not in STOPWORDS and len(word) > 2]
|
title_keywords = [word for word in issue["title"].split() if word.lower() not in STOPWORDS and len(word) > 2]
|
||||||
|
keywords_query = " ".join(title_keywords) if title_keywords else None
|
||||||
if title_keywords:
|
|
||||||
keywords_query = " ".join(title_keywords)
|
|
||||||
queries.append(("title_keywords", f"{base_query} {keywords_query}"))
|
|
||||||
|
|
||||||
for area in detected_areas:
|
|
||||||
queries.append(("area_label", f'{base_query} label:"area:{area}" created:>{sixty_days_ago}'))
|
|
||||||
|
|
||||||
# error pattern search: capture 5–90 chars after keyword, colon optional
|
# error pattern search: capture 5–90 chars after keyword, colon optional
|
||||||
error_pattern = r"(?i:\b(?:error|panicked|panic|failed)\b)\s*([^\n]{5,90})"
|
error_pattern = r"(?i:\b(?:error|panicked|panic|failed)\b)\s*([^\n]{5,90})"
|
||||||
match = re.search(error_pattern, issue["body"])
|
error_match = re.search(error_pattern, issue["body"])
|
||||||
if match:
|
error_snippet = error_match.group(1).strip() if error_match else None
|
||||||
error_snippet = match.group(1).strip()
|
|
||||||
queries.append(("error_pattern", f'{base_query} in:body "{error_snippet}"'))
|
|
||||||
|
|
||||||
for search_type, query in queries[:max_searches]:
|
def build_queries(base, area_window=None):
|
||||||
log(f" Search ({search_type}): {query}")
|
queries = []
|
||||||
try:
|
if keywords_query:
|
||||||
results = github_search_issues(query, per_page=15)
|
queries.append(("title_keywords", f"{base} {keywords_query}"))
|
||||||
for item in results:
|
for area in detected_areas:
|
||||||
number = item["number"]
|
area_q = f'{base} label:"area:{area}"'
|
||||||
if number != issue["number"] and number not in seen_issues:
|
if area_window:
|
||||||
body = item.get("body") or ""
|
area_q += f" created:>{area_window}"
|
||||||
seen_issues[number] = {
|
queries.append(("area_label", area_q))
|
||||||
"number": number,
|
if error_snippet:
|
||||||
"title": item["title"],
|
queries.append(("error_pattern", f'{base} in:body "{error_snippet}"'))
|
||||||
"state": item.get("state", ""),
|
return queries
|
||||||
"created_at": item.get("created_at", ""),
|
|
||||||
"body_preview": body[:1000],
|
open_queries = build_queries(
|
||||||
"source": search_type,
|
f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open",
|
||||||
}
|
area_window=sixty_days_ago,
|
||||||
except requests.RequestException as e:
|
)
|
||||||
log(f" Search failed: {e}")
|
# closed pass: filter by close date so we catch issues closed recently regardless of
|
||||||
|
# when they were opened. closed:> already restricts the result set, so the per-query
|
||||||
|
# area window is unnecessary.
|
||||||
|
closed_queries = build_queries(
|
||||||
|
f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:closed closed:>{thirty_days_ago}",
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_issues = {}
|
||||||
|
for state_label, queries in (
|
||||||
|
("open", open_queries[:max_searches_per_state]),
|
||||||
|
("closed", closed_queries[:max_searches_per_state]),
|
||||||
|
):
|
||||||
|
for search_type, query in queries:
|
||||||
|
log(f" Search ({state_label} / {search_type}): {query}")
|
||||||
|
try:
|
||||||
|
results = github_search_issues(query, per_page=15)
|
||||||
|
for item in results:
|
||||||
|
number = item["number"]
|
||||||
|
if number != issue["number"] and number not in seen_issues:
|
||||||
|
body = item.get("body") or ""
|
||||||
|
seen_issues[number] = {
|
||||||
|
"number": number,
|
||||||
|
"title": item["title"],
|
||||||
|
"state": item.get("state", ""),
|
||||||
|
"state_reason": item.get("state_reason"),
|
||||||
|
"created_at": item.get("created_at", ""),
|
||||||
|
"body_preview": body[:1000],
|
||||||
|
"source": search_type,
|
||||||
|
}
|
||||||
|
except requests.RequestException as e:
|
||||||
|
log(f" Search failed: {e}")
|
||||||
|
|
||||||
similar_issues = list(seen_issues.values())
|
similar_issues = list(seen_issues.values())
|
||||||
log(f" Found {len(similar_issues)} similar issues")
|
log(f" Found {len(similar_issues)} similar issues")
|
||||||
|
|
@ -398,29 +486,41 @@ def search_for_similar_issues(issue, detected_areas, max_searches=6):
|
||||||
|
|
||||||
|
|
||||||
def analyze_duplicates(anthropic_key, issue, magnets, search_results):
|
def analyze_duplicates(anthropic_key, issue, magnets, search_results):
|
||||||
"""Use Claude to analyze potential duplicates."""
|
"""Use Claude to identify duplicates (open) and surface related closed issues.
|
||||||
log("Analyzing duplicates with Claude")
|
|
||||||
|
|
||||||
|
Returns (likely_duplicates, possible_duplicates, related_closed_issues).
|
||||||
|
"""
|
||||||
top_magnets = magnets[:10]
|
top_magnets = magnets[:10]
|
||||||
enrich_magnets(top_magnets)
|
|
||||||
magnet_numbers = {m["number"] for m in top_magnets}
|
magnet_numbers = {m["number"] for m in top_magnets}
|
||||||
|
|
||||||
|
open_results = [r for r in search_results if r["state"] == "open" and r["number"] not in magnet_numbers]
|
||||||
|
closed_results = [r for r in search_results if r["state"] == "closed" and r["number"] not in magnet_numbers]
|
||||||
|
|
||||||
|
if not top_magnets and not open_results and not closed_results:
|
||||||
|
return [], [], []
|
||||||
|
|
||||||
|
log("Analyzing candidates with Claude")
|
||||||
|
enrich_magnets(top_magnets)
|
||||||
|
|
||||||
candidates = [
|
candidates = [
|
||||||
{"number": m["number"], "title": m["title"], "body_preview": m["body_preview"], "source": "known_duplicate_magnet"}
|
{"number": m["number"], "title": m["title"], "body_preview": m["body_preview"],
|
||||||
|
"state": "open", "state_reason": None, "source": "known_duplicate_magnet"}
|
||||||
for m in top_magnets
|
for m in top_magnets
|
||||||
] + [
|
] + [
|
||||||
{"number": r["number"], "title": r["title"], "body_preview": r["body_preview"], "source": "search_result"}
|
{"number": r["number"], "title": r["title"], "body_preview": r["body_preview"],
|
||||||
for r in search_results[:10]
|
"state": r["state"], "state_reason": r["state_reason"], "source": "search_result"}
|
||||||
if r["number"] not in magnet_numbers
|
for r in open_results[:10] + closed_results[:5]
|
||||||
]
|
]
|
||||||
|
|
||||||
if not candidates:
|
system_prompt = """You analyze GitHub issues to (a) identify duplicates among OPEN candidates
|
||||||
return [], "No candidates to analyze"
|
and (b) surface recently CLOSED candidates that are useful triage context.
|
||||||
|
|
||||||
system_prompt = """You analyze GitHub issues to identify potential duplicates.
|
Each candidate has a "state" field ("open" or "closed"), and closed candidates carry a
|
||||||
|
"state_reason" ("completed", "not_planned", or "duplicate").
|
||||||
|
|
||||||
Given a new issue and a list of existing issues, identify which existing issues are duplicates — meaning
|
# (a) Duplicates — OPEN candidates only
|
||||||
they are caused by the SAME BUG in the code, not just similar symptoms.
|
|
||||||
|
A duplicate means: caused by the SAME BUG in the code, not just similar symptoms.
|
||||||
|
|
||||||
CRITICAL DISTINCTION — shared symptoms vs shared root cause:
|
CRITICAL DISTINCTION — shared symptoms vs shared root cause:
|
||||||
- "models missing", "can't sign in", "editor hangs", "venv not detected" are SYMPTOMS that many
|
- "models missing", "can't sign in", "editor hangs", "venv not detected" are SYMPTOMS that many
|
||||||
|
|
@ -428,13 +528,14 @@ CRITICAL DISTINCTION — shared symptoms vs shared root cause:
|
||||||
identify a specific shared root cause.
|
identify a specific shared root cause.
|
||||||
- A duplicate means: if a developer fixed the existing issue, the new issue would also be fixed.
|
- A duplicate means: if a developer fixed the existing issue, the new issue would also be fixed.
|
||||||
- If the issues just happen to be in the same feature area, or describe similar-sounding problems
|
- If the issues just happen to be in the same feature area, or describe similar-sounding problems
|
||||||
with different specifics (different error messages, different triggers, different platforms, different
|
with different specifics (different error messages, different triggers, different platforms,
|
||||||
configurations), they are NOT duplicates.
|
different configurations), they are NOT duplicates.
|
||||||
|
|
||||||
For each potential duplicate, assess confidence:
|
Sort duplicates into two buckets:
|
||||||
- "high": Almost certainly the same bug. You can name a specific shared root cause, and the
|
- "likely_duplicates": Almost certainly the same bug. You can name a specific shared root cause, and
|
||||||
reproduction steps / error messages / triggers are consistent.
|
the reproduction steps / error messages / triggers are consistent.
|
||||||
- "medium": Likely the same bug based on specific technical details, but some uncertainty remains.
|
- "possible_duplicates": Likely the same bug based on specific technical details, but some
|
||||||
|
uncertainty remains.
|
||||||
- Do NOT include issues that merely share symptoms, affect the same feature area, or sound similar
|
- Do NOT include issues that merely share symptoms, affect the same feature area, or sound similar
|
||||||
at a surface level.
|
at a surface level.
|
||||||
|
|
||||||
|
|
@ -444,24 +545,48 @@ Examples of things that are NOT duplicates:
|
||||||
- Two issues about "Zed hangs" — one triggered by network drives, the other by large projects.
|
- Two issues about "Zed hangs" — one triggered by network drives, the other by large projects.
|
||||||
- Two issues about "can't sign in" — one caused by a missing system package, the other by a server-side error.
|
- Two issues about "can't sign in" — one caused by a missing system package, the other by a server-side error.
|
||||||
|
|
||||||
Output only valid JSON (no markdown code blocks) with this structure:
|
For OPEN duplicates (either bucket), false positives are MUCH worse than false negatives — they
|
||||||
|
waste the time of both the issue author and the maintainers. When in doubt, omit.
|
||||||
|
|
||||||
|
# (b) Related closed issues — CLOSED candidates only
|
||||||
|
|
||||||
|
The goal is to give triagers extra context, NOT to claim a duplicate. The bar is lower than for
|
||||||
|
duplicates: include a closed candidate if a triager would plausibly want to see it when reviewing
|
||||||
|
the new issue. Examples worth surfacing:
|
||||||
|
- A recently fixed (state_reason "completed") issue describing the same symptom — triager may ask
|
||||||
|
the reporter to retest on the latest build.
|
||||||
|
- A cluster of similar issues closed as "not_planned" — signals a known limitation or design choice.
|
||||||
|
- A previously triaged duplicate (state_reason "duplicate") in the same code area.
|
||||||
|
|
||||||
|
Include at most 5 closed candidates, prioritized by relevance.
|
||||||
|
|
||||||
|
# Output format
|
||||||
|
|
||||||
|
Output only valid JSON (no markdown code blocks):
|
||||||
{
|
{
|
||||||
"matches": [
|
"likely_duplicates": [
|
||||||
{
|
{
|
||||||
"number": 12345,
|
"number": 12345,
|
||||||
"confidence": "high|medium",
|
|
||||||
"shared_root_cause": "The specific bug/root cause shared by both issues",
|
"shared_root_cause": "The specific bug/root cause shared by both issues",
|
||||||
"explanation": "Brief explanation with concrete evidence from both issues"
|
"explanation": "Brief explanation with concrete evidence from both issues"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "One sentence summary of findings"
|
"possible_duplicates": [
|
||||||
|
{
|
||||||
|
"number": 12345,
|
||||||
|
"shared_root_cause": "The specific bug/root cause shared by both issues",
|
||||||
|
"explanation": "Brief explanation with concrete evidence from both issues"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"related_closed_issues": [
|
||||||
|
{
|
||||||
|
"number": 12345,
|
||||||
|
"explanation": "Brief explanation of why this is useful triage context"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
When in doubt, return an empty matches array. A false positive (flagging a non-duplicate) is much
|
Return empty arrays where nothing relevant is found."""
|
||||||
worse than a false negative (missing a real duplicate), because it wastes the time of both the
|
|
||||||
issue author and the maintainers.
|
|
||||||
|
|
||||||
Return empty matches array if none found or if you can only identify shared symptoms."""
|
|
||||||
|
|
||||||
user_content = f"""## New Issue #{issue['number']}
|
user_content = f"""## New Issue #{issue['number']}
|
||||||
**Title:** {issue['title']}
|
**Title:** {issue['title']}
|
||||||
|
|
@ -474,17 +599,23 @@ Return empty matches array if none found or if you can only identify shared symp
|
||||||
|
|
||||||
response = call_claude(anthropic_key, system_prompt, user_content, max_tokens=2048)
|
response = call_claude(anthropic_key, system_prompt, user_content, max_tokens=2048)
|
||||||
|
|
||||||
|
# Claude sometimes wraps JSON in a ```json ... ``` fence despite the prompt forbidding it
|
||||||
|
fence = re.match(r"^\s*```(?:json)?\s*\n?(.*?)\n?```\s*$", response, re.DOTALL)
|
||||||
|
if fence:
|
||||||
|
response = fence.group(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(response)
|
data = json.loads(response)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
log(f" Failed to parse response: {e}")
|
log(f" Failed to parse Claude response as JSON: {e}")
|
||||||
log(f" Raw response: {response}")
|
log(f" Raw response:\n{response}")
|
||||||
return [], "Failed to parse analysis"
|
sys.exit(1)
|
||||||
|
|
||||||
matches = data.get("matches", [])
|
likely = data.get("likely_duplicates", [])
|
||||||
summary = data.get("summary", "Analysis complete")
|
possible = data.get("possible_duplicates", [])
|
||||||
log(f" Found {len(matches)} potential matches")
|
closed = data.get("related_closed_issues", [])
|
||||||
return matches, summary
|
log(f" Found {len(likely) + len(possible) + len(closed)} potential matches")
|
||||||
|
return likely, possible, closed
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
@ -515,35 +646,39 @@ if __name__ == "__main__":
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# detect areas
|
# detect areas
|
||||||
taxonomy = format_taxonomy_for_claude(fetch_area_labels())
|
detected_areas = detect_areas(anthropic_key, issue, fetch_area_labels())
|
||||||
detected_areas = detect_areas(anthropic_key, issue, taxonomy)
|
|
||||||
|
|
||||||
# search for potential duplicates
|
# search for potential duplicates and related closed issues
|
||||||
all_magnets = parse_duplicate_magnets()
|
all_magnets = parse_duplicate_magnets()
|
||||||
relevant_magnets = filter_magnets_by_areas(all_magnets, detected_areas)
|
relevant_magnets = filter_magnets_by_areas(all_magnets, detected_areas)
|
||||||
search_results = search_for_similar_issues(issue, detected_areas)
|
search_results = search_for_similar_issues(issue, detected_areas)
|
||||||
|
|
||||||
# analyze potential duplicates
|
# analyze candidates
|
||||||
if relevant_magnets or search_results:
|
likely_duplicates, possible_duplicates, related_closed_issues = analyze_duplicates(
|
||||||
matches, summary = analyze_duplicates(anthropic_key, issue, relevant_magnets, search_results)
|
anthropic_key, issue, relevant_magnets, search_results
|
||||||
else:
|
)
|
||||||
matches, summary = [], "No potential duplicates to analyze"
|
|
||||||
|
|
||||||
# post comment if high-confidence matches found
|
# resolve close reason from our search results (the source of truth) so we don't depend
|
||||||
high_confidence_matches = [m for m in matches if m["confidence"] == "high"]
|
# on Claude to faithfully echo it back
|
||||||
|
results_by_number = {r["number"]: r for r in search_results}
|
||||||
|
for m in related_closed_issues:
|
||||||
|
m["state_reason"] = results_by_number[m["number"]]["state_reason"]
|
||||||
|
|
||||||
|
comment_body = build_comment(likely_duplicates, possible_duplicates, related_closed_issues)
|
||||||
commented = False
|
commented = False
|
||||||
|
|
||||||
if high_confidence_matches:
|
if comment_body:
|
||||||
comment_body = build_duplicate_comment(high_confidence_matches)
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
log("Dry run - would post comment:\n" + "-" * 40 + "\n" + comment_body + "\n" + "-" * 40)
|
log("Dry run - would post comment:\n" + "-" * 40 + "\n" + comment_body + "\n" + "-" * 40)
|
||||||
else:
|
else:
|
||||||
log("Posting comment for high-confidence match(es)")
|
log("Posting comment")
|
||||||
try:
|
try:
|
||||||
post_comment(issue["number"], comment_body)
|
post_comment(issue["number"], comment_body)
|
||||||
commented = True
|
commented = True
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
log(f" Failed to post comment: {e}")
|
log(f" Failed to post comment: {e}")
|
||||||
|
log(f" Comment we were trying to post:\n{comment_body}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"skipped": False,
|
"skipped": False,
|
||||||
|
|
@ -556,7 +691,8 @@ if __name__ == "__main__":
|
||||||
"detected_areas": detected_areas,
|
"detected_areas": detected_areas,
|
||||||
"magnets_count": len(relevant_magnets),
|
"magnets_count": len(relevant_magnets),
|
||||||
"search_results_count": len(search_results),
|
"search_results_count": len(search_results),
|
||||||
"matches": matches,
|
"likely_duplicates": likely_duplicates,
|
||||||
"summary": summary,
|
"possible_duplicates": possible_duplicates,
|
||||||
|
"related_closed_issues": related_closed_issues,
|
||||||
"commented": commented,
|
"commented": commented,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,23 @@ REPO_NAME = "zed"
|
||||||
STAFF_TEAM_SLUG = "staff"
|
STAFF_TEAM_SLUG = "staff"
|
||||||
BOT_LOGIN = "zed-community-bot[bot]"
|
BOT_LOGIN = "zed-community-bot[bot]"
|
||||||
BOT_APP_SLUG = "zed-community-bot"
|
BOT_APP_SLUG = "zed-community-bot"
|
||||||
BOT_COMMENT_PREFIX = "This issue appears to be a duplicate of"
|
# Strings that identify a comment posted by the duplicate-detection bot. Any
|
||||||
|
# match counts as a bot comment for classification purposes. A single comment
|
||||||
|
# can contain both markers (v3+ produces this when there are both confident
|
||||||
|
# duplicates and lower-confidence triage context).
|
||||||
|
BOT_COMMENT_MARKERS = (
|
||||||
|
"This issue appears to be a duplicate of", # user-facing duplicate alert
|
||||||
|
"Additional recent context for triagers", # v3+ collapsed triage section
|
||||||
|
)
|
||||||
BOT_START_DATE = "2026-02-18"
|
BOT_START_DATE = "2026-02-18"
|
||||||
NEEDS_TRIAGE_LABEL = "state:needs triage"
|
NEEDS_TRIAGE_LABEL = "state:needs triage"
|
||||||
DEFAULT_PROJECT_NUMBER = 76
|
DEFAULT_PROJECT_NUMBER = 76
|
||||||
VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"}
|
VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"}
|
||||||
# Add a new tuple when you deploy a new version of the bot that you want to
|
# Add a new tuple when you deploy a new version of the bot that you want to
|
||||||
# keep track of (e.g. the prompt gets a rewrite or the model gets swapped).
|
# keep track of (e.g. the prompt gets a rewrite or the model gets swapped).
|
||||||
# Newest first, please. The datetime is for the deployment time (merge to maain).
|
# Newest first, please. The datetime is for the deployment time (merge to main).
|
||||||
BOT_VERSION_TIMELINE = [
|
BOT_VERSION_TIMELINE = [
|
||||||
|
("v3", datetime(2026, 5, 25, 14, 30, tzinfo=timezone.utc)),
|
||||||
("v2", datetime(2026, 2, 26, 14, 9, tzinfo=timezone.utc)),
|
("v2", datetime(2026, 2, 26, 14, 9, tzinfo=timezone.utc)),
|
||||||
("v1", datetime(2026, 2, 18, tzinfo=timezone.utc)),
|
("v1", datetime(2026, 2, 18, tzinfo=timezone.utc)),
|
||||||
]
|
]
|
||||||
|
|
@ -96,10 +104,16 @@ def fetch_issue(issue_number):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_bot_dupe_comment(body):
|
||||||
|
"""True if the comment body looks like one posted by the duplicate-detection bot."""
|
||||||
|
return any(marker in body for marker in BOT_COMMENT_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
def get_bot_comment_with_time(issue_number):
|
def get_bot_comment_with_time(issue_number):
|
||||||
"""Get the bot's duplicate-detection comment and its timestamp from an issue.
|
"""Get the bot's duplicate-detection comment and its timestamp from an issue.
|
||||||
|
|
||||||
Returns {"body": str, "created_at": str} if found, else None.
|
Recognizes both the user-facing duplicate alert and the v3+ triage-only
|
||||||
|
comment formats. Returns {"body": str, "created_at": str} if found, else None.
|
||||||
"""
|
"""
|
||||||
comments_path = f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
|
comments_path = f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
|
||||||
page = 1
|
page = 1
|
||||||
|
|
@ -107,7 +121,7 @@ def get_bot_comment_with_time(issue_number):
|
||||||
for comment in comments:
|
for comment in comments:
|
||||||
author = (comment.get("user") or {}).get("login", "")
|
author = (comment.get("user") or {}).get("login", "")
|
||||||
body = comment.get("body", "")
|
body = comment.get("body", "")
|
||||||
if author == BOT_LOGIN and body.startswith(BOT_COMMENT_PREFIX):
|
if author == BOT_LOGIN and is_bot_dupe_comment(body):
|
||||||
return {"body": body, "created_at": comment.get("created_at", "")}
|
return {"body": body, "created_at": comment.get("created_at", "")}
|
||||||
page += 1
|
page += 1
|
||||||
return None
|
return None
|
||||||
|
|
@ -448,7 +462,7 @@ def classify_open():
|
||||||
node_id = item["node_id"]
|
node_id = item["node_id"]
|
||||||
|
|
||||||
skip_reason = (
|
skip_reason = (
|
||||||
f"type is {type_name}" if type_name not in ("Bug", "Crash")
|
f"type is {type_name}" if type_name and type_name not in ("Bug", "Crash")
|
||||||
else f"author {author} is staff" if is_staff_member(author)
|
else f"author {author} is staff" if is_staff_member(author)
|
||||||
else "already on the board" if find_project_item(node_id)
|
else "already on the board" if find_project_item(node_id)
|
||||||
else "no bot duplicate comment found" if not (bot_comment := get_bot_comment_with_time(number))
|
else "no bot duplicate comment found" if not (bot_comment := get_bot_comment_with_time(number))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue