mirror of
https://github.com/zed-industries/zed.git
synced 2026-07-12 17:31:04 +00:00
Compare commits
61 commits
v1.11.0-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60099a06df | ||
|
|
49ad06c1b4 | ||
|
|
b0da438545 | ||
|
|
9c3273201c | ||
|
|
df37766060 | ||
|
|
65e1c5af25 | ||
|
|
bc99075373 | ||
|
|
5f8a7413a3 | ||
|
|
8f92822cbf | ||
|
|
7344a31ddc | ||
|
|
2b9b3c7ea2 | ||
|
|
01f915d6be | ||
|
|
04d03d1fe5 | ||
|
|
bd2e879213 | ||
|
|
503292376e | ||
|
|
c5383ce5d4 | ||
|
|
0953838ec2 | ||
|
|
585e4aab08 | ||
|
|
5cf706ceeb | ||
|
|
fbceb2823b | ||
|
|
60314a7416 | ||
|
|
af56cb75a8 | ||
|
|
b2db24e58a | ||
|
|
9db37846a6 | ||
|
|
76c93968da | ||
|
|
a5f696bfa3 | ||
|
|
2c4e44704c | ||
|
|
35ffa8f480 | ||
|
|
d8c8040462 | ||
|
|
3c45bfb205 | ||
|
|
717e1c0590 | ||
|
|
da4703be85 | ||
|
|
11d216d8bf | ||
|
|
e2d41c477c | ||
|
|
8230cb16d1 | ||
|
|
7828463fcb | ||
|
|
3e976e89bc | ||
|
|
55b776183d | ||
|
|
9b4598f708 | ||
|
|
8f907c5ee1 | ||
|
|
9cdaff1492 | ||
|
|
0bc9c99f00 | ||
|
|
4d6762ad6b | ||
|
|
c93b4cd9cc | ||
|
|
57261fef89 | ||
|
|
f281770034 | ||
|
|
10504e3ce1 | ||
|
|
dd68454633 | ||
|
|
664b7ecb40 | ||
|
|
23c0080d1d | ||
|
|
546a16d64f | ||
|
|
6b733d1058 | ||
|
|
b9f3396b68 | ||
|
|
7dc634124c | ||
|
|
029bf2f284 | ||
|
|
7f5cf583dc | ||
|
|
6979d7281f | ||
|
|
d9e35975c2 | ||
|
|
74b5207744 | ||
|
|
c80020231b | ||
|
|
74798c68d5 |
216 changed files with 15441 additions and 2398 deletions
|
|
@ -1,6 +1,11 @@
|
|||
export default {
|
||||
async fetch(request, _env, _ctx) {
|
||||
const url = new URL(request.url);
|
||||
const acceptHeader = request.headers.get("Accept") || "";
|
||||
const wantsMarkdown = acceptHeader
|
||||
.split(",")
|
||||
.map((mediaType) => mediaType.split(";")[0].trim().toLowerCase())
|
||||
.includes("text/markdown");
|
||||
|
||||
if (url.pathname === "/docs/nightly") {
|
||||
url.hostname = "docs-nightly.pages.dev";
|
||||
|
|
@ -18,6 +23,14 @@ export default {
|
|||
url.hostname = "docs-anw.pages.dev";
|
||||
}
|
||||
|
||||
if (url.pathname === "/docs.md") {
|
||||
url.pathname = "/docs/getting-started.md";
|
||||
}
|
||||
|
||||
if (wantsMarkdown) {
|
||||
url.pathname = markdownPathFor(url.pathname);
|
||||
}
|
||||
|
||||
let res = await fetch(url, request);
|
||||
|
||||
if (res.status === 404) {
|
||||
|
|
@ -27,3 +40,31 @@ export default {
|
|||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
function markdownPathFor(pathname) {
|
||||
if (pathname === "/docs" || pathname === "/docs/") {
|
||||
return "/docs/getting-started.md";
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/index.md")) {
|
||||
return pathname.replace(/\/index\.md$/, "/getting-started.md");
|
||||
}
|
||||
|
||||
if (pathname.endsWith(".md")) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
if (pathname.endsWith(".html")) {
|
||||
return pathname.replace(/\.html$/, ".md");
|
||||
}
|
||||
|
||||
if (pathname.split("/").pop().includes(".")) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/")) {
|
||||
return `${pathname}getting-started.md`;
|
||||
}
|
||||
|
||||
return `${pathname}.md`;
|
||||
}
|
||||
|
|
|
|||
9
.github/workflows/cherry_pick.yml
vendored
9
.github/workflows/cherry_pick.yml
vendored
|
|
@ -27,10 +27,6 @@ jobs:
|
|||
run_cherry_pick:
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- name: steps::checkout_repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
with:
|
||||
clean: false
|
||||
- id: generate-token
|
||||
name: steps::authenticate_as_zippy
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
|
||||
|
|
@ -40,6 +36,11 @@ jobs:
|
|||
permission-contents: write
|
||||
permission-workflows: write
|
||||
permission-pull-requests: write
|
||||
- name: steps::checkout_repo
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
with:
|
||||
clean: false
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
- name: cherry_pick::run_cherry_pick::cherry_pick
|
||||
run: ./script/cherry-pick "$BRANCH" "$COMMIT" "$CHANNEL"
|
||||
env:
|
||||
|
|
|
|||
15
.github/workflows/guild_new_pr_notify.yml
vendored
15
.github/workflows/guild_new_pr_notify.yml
vendored
|
|
@ -40,18 +40,23 @@ jobs:
|
|||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const GUILD_TEAM_SLUG = 'guild-cohort-2';
|
||||
// Guild cohort members are outside collaborators holding this custom
|
||||
// repository role, not members of an org team.
|
||||
const GUILD_ROLE_NAME = 'Guild Assign issues/PRs';
|
||||
const pr = context.payload.pull_request;
|
||||
const author = pr.user.login;
|
||||
|
||||
const isGuildMember = async (username) => {
|
||||
try {
|
||||
const response = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: 'zed-industries',
|
||||
team_slug: GUILD_TEAM_SLUG,
|
||||
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: 'zed-industries',
|
||||
repo: 'zed',
|
||||
username
|
||||
});
|
||||
return response.data.state === 'active';
|
||||
// role_name is the effective (highest) role; for cohort outside
|
||||
// collaborators that is the custom role. Built-in roles come back
|
||||
// lowercased and won't match.
|
||||
return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
return false;
|
||||
|
|
|
|||
24
.github/workflows/pr_issue_labeler.yml
vendored
24
.github/workflows/pr_issue_labeler.yml
vendored
|
|
@ -41,7 +41,9 @@ jobs:
|
|||
const STAFF_TEAM_SLUG = 'staff';
|
||||
const FIRST_CONTRIBUTION_LABEL = 'first contribution';
|
||||
const GUILD_LABEL = 'guild';
|
||||
const GUILD_TEAM_SLUG = 'guild-cohort-2';
|
||||
// Guild cohort members are outside collaborators holding this custom
|
||||
// repository role, not members of an org team.
|
||||
const GUILD_ROLE_NAME = 'Guild Assign issues/PRs';
|
||||
const COMMUNITY_CHAMPION_LABEL = 'community champion';
|
||||
const COMMUNITY_CHAMPIONS = [
|
||||
'0x2CA',
|
||||
|
|
@ -138,7 +140,25 @@ jobs:
|
|||
};
|
||||
|
||||
const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author);
|
||||
const isGuildMember = (author) => isTeamMember(GUILD_TEAM_SLUG, author);
|
||||
|
||||
const isGuildMember = async (author) => {
|
||||
try {
|
||||
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: 'zed-industries',
|
||||
repo: 'zed',
|
||||
username: author
|
||||
});
|
||||
// role_name is the effective (highest) role; for cohort outside
|
||||
// collaborators that is the custom role. Built-in roles come back
|
||||
// lowercased and won't match.
|
||||
return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase();
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getIssueLabels = () => {
|
||||
if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) {
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -760,6 +760,8 @@ jobs:
|
|||
needs:
|
||||
- upload_release_assets
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: release::validate_release_assets
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -11,28 +11,41 @@ env:
|
|||
PRIORITY_LABELS: '["priority:P0", "priority:P1"]'
|
||||
REPRODUCIBLE_LABEL: 'state:reproducible'
|
||||
FREQUENCY_LABELS: '["frequency:always", "frequency:common"]'
|
||||
MARKER_REACTION: 'eyes'
|
||||
|
||||
jobs:
|
||||
notify-slack:
|
||||
if: github.repository_owner == 'zed-industries' && github.event.issue.state == 'open'
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
# Serialize per-issue so concurrent `labeled` events can't both observe
|
||||
# the trifecta and double-notify.
|
||||
permissions:
|
||||
contents: read
|
||||
# For the issue reaction used to dedupe notifications.
|
||||
issues: write
|
||||
# Serialize per issue so the reaction claim below can't race.
|
||||
concurrency:
|
||||
group: slack-notify-first-responders-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Check if label combination requires first responder notification
|
||||
- name: Check label combination and claim the notification
|
||||
id: check-label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
ISSUE_LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Gate on the just-added label so unrelated labeling on an
|
||||
# already-qualifying issue doesn't re-fire the notification.
|
||||
api() {
|
||||
curl -sS \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# Ignore labels outside the trifecta so unrelated later edits don't re-fire.
|
||||
TRIGGER_LABELS=$(jq -cn \
|
||||
--argjson priority "$PRIORITY_LABELS" \
|
||||
--arg repro "$REPRODUCIBLE_LABEL" \
|
||||
|
|
@ -45,19 +58,52 @@ jobs:
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# The webhook's issue.labels snapshot is racy under bulk apply; read live.
|
||||
ISSUE_LABELS_JSON=$(api -f "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/labels?per_page=100" | jq '[.[].name]')
|
||||
|
||||
MATCHED_PRIORITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson priority "$PRIORITY_LABELS" 'map(select(. as $x | $priority | index($x) != null)) | first // ""')
|
||||
HAS_REPRO=$(echo "$ISSUE_LABELS_JSON" | jq --arg l "$REPRODUCIBLE_LABEL" 'index($l) != null')
|
||||
HAS_FREQ=$(echo "$ISSUE_LABELS_JSON" | jq --argjson freq "$FREQUENCY_LABELS" 'any(.[]; . as $x | $freq | index($x) != null)')
|
||||
|
||||
if [ -n "$MATCHED_PRIORITY" ] && [ "$HAS_REPRO" = "true" ] && [ "$HAS_FREQ" = "true" ]; then
|
||||
echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying"
|
||||
echo "notify_reason=confirmed $MATCHED_PRIORITY" >> "$GITHUB_OUTPUT"
|
||||
echo "should_notify=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
if [ -z "$MATCHED_PRIORITY" ] || [ "$HAS_REPRO" != "true" ] || [ "$HAS_FREQ" != "true" ]; then
|
||||
echo "Combination not yet satisfied (priority=$MATCHED_PRIORITY, reproducible=$HAS_REPRO, frequency=$HAS_FREQ), skipping"
|
||||
echo "should_notify=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A bulk label apply emits one `labeled` event per label, so several
|
||||
# runs reach this point. Creating the reaction is our atomic claim:
|
||||
# one run gets 201 and notifies, the rest get 200. It's scoped to our
|
||||
# own reaction, so a human's reaction can't suppress it.
|
||||
REACTION_PAYLOAD=$(jq -cn --arg content "$MARKER_REACTION" '{content: $content}')
|
||||
REACTION_RESPONSE=$(api -w "\n%{http_code}" -X POST \
|
||||
"https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions" \
|
||||
-d "$REACTION_PAYLOAD")
|
||||
REACTION_BODY=$(echo "$REACTION_RESPONSE" | sed '$d')
|
||||
REACTION_STATUS=$(echo "$REACTION_RESPONSE" | tail -n1)
|
||||
|
||||
if [ "$REACTION_STATUS" = "200" ]; then
|
||||
echo "First responders already notified for this issue (reaction present), skipping"
|
||||
echo "should_notify=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$REACTION_STATUS" != "201" ]; then
|
||||
echo "::error::Unexpected status $REACTION_STATUS creating reaction: $REACTION_BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REACTION_ID=$(echo "$REACTION_BODY" | jq -r '.id')
|
||||
LABELS=$(echo "$ISSUE_LABELS_JSON" | jq -r 'join(", ")')
|
||||
|
||||
echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying"
|
||||
{
|
||||
echo "notify_reason=confirmed $MATCHED_PRIORITY"
|
||||
echo "issue_labels=$LABELS"
|
||||
echo "reaction_id=$REACTION_ID"
|
||||
echo "should_notify=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Slack message payload
|
||||
if: steps.check-label.outputs.should_notify == 'true'
|
||||
env:
|
||||
|
|
@ -65,10 +111,8 @@ jobs:
|
|||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
LABELED_BY: ${{ github.event.sender.login }}
|
||||
NOTIFY_REASON: ${{ steps.check-label.outputs.notify_reason }}
|
||||
LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }}
|
||||
LABELS: ${{ steps.check-label.outputs.issue_labels }}
|
||||
run: |
|
||||
LABELS=$(echo "$LABELS_JSON" | jq -r 'join(", ")')
|
||||
|
||||
jq -n \
|
||||
--arg notify_reason "$NOTIFY_REASON" \
|
||||
--arg issue_title "$ISSUE_TITLE" \
|
||||
|
|
@ -111,9 +155,27 @@ jobs:
|
|||
if: steps.check-label.outputs.should_notify == 'true'
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_FIRST_RESPONDERS }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REACTION_ID: ${{ steps.check-label.outputs.reaction_id }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
release_claim() {
|
||||
if [ -n "${REACTION_ID:-}" ]; then
|
||||
echo "Releasing reaction claim so the notification can be retried"
|
||||
curl -sS -X DELETE \
|
||||
-H "Authorization: Bearer $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions/$REACTION_ID" || true
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -z "$SLACK_WEBHOOK_URL" ]; then
|
||||
echo "::error::SLACK_WEBHOOK_FIRST_RESPONDERS secret is not set"
|
||||
release_claim
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -129,6 +191,7 @@ jobs:
|
|||
|
||||
if [ "$HTTP_STATUS" -ne 200 ]; then
|
||||
echo "::error::Slack notification failed with status $HTTP_STATUS: $HTTP_BODY"
|
||||
release_claim
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
37
Cargo.lock
generated
37
Cargo.lock
generated
|
|
@ -494,6 +494,7 @@ dependencies = [
|
|||
"heapless",
|
||||
"html_to_markdown",
|
||||
"http_client",
|
||||
"idna",
|
||||
"image",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
|
|
@ -549,6 +550,7 @@ dependencies = [
|
|||
"tree-sitter-md",
|
||||
"ui",
|
||||
"ui_input",
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"unindent",
|
||||
"url",
|
||||
|
|
@ -5344,9 +5346,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
|
@ -6465,9 +6467,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8"
|
||||
checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
|
|
@ -14003,7 +14005,7 @@ dependencies = [
|
|||
"dap",
|
||||
"encoding_rs",
|
||||
"extension",
|
||||
"fancy-regex 0.17.0",
|
||||
"fancy-regex 0.18.0",
|
||||
"fs",
|
||||
"futures 0.3.32",
|
||||
"fuzzy",
|
||||
|
|
@ -15066,9 +15068,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
|
|
@ -15995,6 +15997,7 @@ dependencies = [
|
|||
"libc",
|
||||
"log",
|
||||
"nix 0.29.0",
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smol",
|
||||
|
|
@ -16312,6 +16315,15 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "seccompiler"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.10.3"
|
||||
|
|
@ -16724,6 +16736,7 @@ dependencies = [
|
|||
"agent_skills",
|
||||
"anyhow",
|
||||
"audio",
|
||||
"client",
|
||||
"cloud_api_types",
|
||||
"codestral",
|
||||
"collections",
|
||||
|
|
@ -20843,9 +20856,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.11"
|
||||
version = "0.3.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
|
||||
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
|
|
@ -20929,9 +20942,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.7"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
|
|
@ -22994,7 +23007,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
dependencies = [
|
||||
"acp_thread",
|
||||
"acp_tools",
|
||||
|
|
|
|||
|
|
@ -595,7 +595,7 @@ emojis = "0.6.1"
|
|||
env_logger = "0.11"
|
||||
encoding_rs = "0.8"
|
||||
exec = "0.3.1"
|
||||
fancy-regex = "0.17.0"
|
||||
fancy-regex = "0.18.0"
|
||||
fork = "0.4.0"
|
||||
futures = "0.3.32"
|
||||
futures-concurrency = "7.7.1"
|
||||
|
|
@ -744,6 +744,7 @@ rustls-platform-verifier = "0.5.0"
|
|||
# WARNING: If you change this, you must also publish a new version of zed-scap to crates.io
|
||||
scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" }
|
||||
schemars = { version = "1.0", features = ["indexmap2"] }
|
||||
seccompiler = "0.5"
|
||||
semver = { version = "1.0", features = ["serde"] }
|
||||
serde = { version = "1.0.221", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] }
|
||||
|
|
|
|||
|
|
@ -298,13 +298,6 @@
|
|||
"alt-r": "search::ToggleRegex",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentTerminalThread",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-f": "agent::ToggleSearch",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AcpThreadSearchBar",
|
||||
"use_key_equivalents": true,
|
||||
|
|
@ -326,6 +319,7 @@
|
|||
"context": "AcpThread > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-f": "agent::ToggleSearch",
|
||||
"ctrl-alt-pageup": "agent::ScrollOutputPageUp",
|
||||
"ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
|
||||
"ctrl-alt-home": "agent::ScrollOutputToTop",
|
||||
|
|
@ -1240,6 +1234,7 @@
|
|||
"ctrl-c": ["terminal::SendKeystroke", "ctrl-c"],
|
||||
"ctrl-e": ["terminal::SendKeystroke", "ctrl-e"],
|
||||
"ctrl-o": ["terminal::SendKeystroke", "ctrl-o"],
|
||||
"ctrl-q": ["terminal::SendKeystroke", "ctrl-q"],
|
||||
"ctrl-w": ["terminal::SendKeystroke", "ctrl-w"],
|
||||
"ctrl-r": ["terminal::SendKeystroke", "ctrl-r"],
|
||||
"ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"],
|
||||
|
|
@ -1270,7 +1265,9 @@
|
|||
},
|
||||
{
|
||||
"context": "AgentPanel > Terminal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-f": "agent::ToggleSearch",
|
||||
"ctrl-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -336,13 +336,6 @@
|
|||
"alt-cmd-x": "search::ToggleRegex",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentTerminalThread",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-f": "agent::ToggleSearch",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AcpThreadSearchBar",
|
||||
"use_key_equivalents": true,
|
||||
|
|
@ -364,6 +357,7 @@
|
|||
"context": "AcpThread > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-f": "agent::ToggleSearch",
|
||||
"ctrl-pageup": "agent::ScrollOutputPageUp",
|
||||
"ctrl-pagedown": "agent::ScrollOutputPageDown",
|
||||
"ctrl-home": "agent::ScrollOutputToTop",
|
||||
|
|
@ -1341,6 +1335,7 @@
|
|||
"context": "AgentPanel > Terminal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-f": "agent::ToggleSearch",
|
||||
"cmd-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -299,13 +299,6 @@
|
|||
"alt-r": "search::ToggleRegex",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AgentTerminalThread",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-f": "agent::ToggleSearch",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "AcpThreadSearchBar",
|
||||
"use_key_equivalents": true,
|
||||
|
|
@ -327,6 +320,7 @@
|
|||
"context": "AcpThread > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-f": "agent::ToggleSearch",
|
||||
"ctrl-alt-pageup": "agent::ScrollOutputPageUp",
|
||||
"ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
|
||||
"ctrl-alt-home": "agent::ScrollOutputToTop",
|
||||
|
|
@ -1285,6 +1279,7 @@
|
|||
"context": "AgentPanel > Terminal",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-f": "agent::ToggleSearch",
|
||||
"ctrl-n": "agent::NewThread",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1458,7 +1458,13 @@
|
|||
// The EditorConfig `end_of_line` property overrides this setting and behaves
|
||||
// like `enforce_lf` or `enforce_crlf`.
|
||||
"line_ending": "detect",
|
||||
// Whether or not to perform a buffer format before saving: [on, off]
|
||||
// Whether or not to perform a buffer format before saving:
|
||||
// "on" — format the whole buffer
|
||||
// "off" — do not format
|
||||
// "modifications" — format only lines with unstaged changes; skips formatting
|
||||
// when no git diff is available or the language server lacks range formatting
|
||||
// "modifications_if_available" — same, but falls back to formatting the whole
|
||||
// buffer when range formatting cannot be used
|
||||
// Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored
|
||||
"format_on_save": "off",
|
||||
// How to perform a buffer format. This setting can take multiple values:
|
||||
|
|
|
|||
|
|
@ -224,6 +224,11 @@ pub struct SandboxFallbackAuthorizationDetails {
|
|||
/// whether to run the command without a sandbox.
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
/// Slug of the sandboxing docs section that best explains how to fix this
|
||||
/// failure (see [`crate::LinuxWslSandboxError::docs_section`]), rendered as a
|
||||
/// "Learn more" link. `None` when the cause is unknown.
|
||||
#[serde(default)]
|
||||
pub docs_section: Option<String>,
|
||||
}
|
||||
|
||||
pub fn meta_with_sandbox_fallback_authorization(
|
||||
|
|
@ -430,16 +435,7 @@ impl ElicitationStore {
|
|||
&self.elicitations
|
||||
}
|
||||
|
||||
fn validate_request(
|
||||
request: &acp::CreateElicitationRequest,
|
||||
cx: &App,
|
||||
) -> Result<(), acp::Error> {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return Err(
|
||||
acp::Error::invalid_params().data("elicitation support requires the ACP beta flag")
|
||||
);
|
||||
}
|
||||
|
||||
fn validate_request(request: &acp::CreateElicitationRequest) -> Result<(), acp::Error> {
|
||||
if let acp::ElicitationMode::Url(mode) = &request.mode {
|
||||
url::Url::parse(&mode.url)
|
||||
.map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?;
|
||||
|
|
@ -590,7 +586,7 @@ impl ElicitationStore {
|
|||
request: acp::CreateElicitationRequest,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(ElicitationEntryId, Task<acp::CreateElicitationResponse>), acp::Error> {
|
||||
Self::validate_request(&request, cx)?;
|
||||
Self::validate_request(&request)?;
|
||||
let (id, response_rx) = self.insert_pending_elicitation(request);
|
||||
cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone()));
|
||||
cx.notify();
|
||||
|
|
@ -3485,7 +3481,7 @@ impl AcpThread {
|
|||
request: acp::CreateElicitationRequest,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(ElicitationEntryId, Task<acp::CreateElicitationResponse>), acp::Error> {
|
||||
ElicitationStore::validate_request(&request, cx)?;
|
||||
ElicitationStore::validate_request(&request)?;
|
||||
|
||||
let (id, response_rx) = self.elicitations.insert_pending_elicitation(request);
|
||||
self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx);
|
||||
|
|
@ -7416,7 +7412,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_elicitation_requires_acp_beta_flag(cx: &mut TestAppContext) {
|
||||
async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.update_flags(false, vec![]);
|
||||
|
|
@ -7438,8 +7434,13 @@ mod tests {
|
|||
)
|
||||
});
|
||||
|
||||
assert!(result.is_err());
|
||||
thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty()));
|
||||
assert!(result.is_ok());
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert!(matches!(
|
||||
thread.entries(),
|
||||
[AgentThreadEntry::Elicitation(_)]
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
|
|||
|
|
@ -129,6 +129,31 @@ impl LinuxWslSandboxError {
|
|||
LinuxWslSandboxError::Other(message) => message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The slug of the sandboxing docs section that best explains how to resolve
|
||||
/// this failure, for deep-linking from the UI. Pair with
|
||||
/// `client::zed_urls::sandboxing_docs`.
|
||||
pub fn docs_section(&self) -> &'static str {
|
||||
match self {
|
||||
// Both "no bwrap" and "only a setuid-root bwrap" are resolved by
|
||||
// installing a non-setuid Bubblewrap.
|
||||
LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => {
|
||||
"installing-bubblewrap"
|
||||
}
|
||||
// A failed probe on Linux is almost always disabled unprivileged
|
||||
// user namespaces, which the Ubuntu-specific section covers.
|
||||
LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu",
|
||||
// Catch-all (includes WSL/Windows messages): point at the platform
|
||||
// overview for the current OS.
|
||||
LinuxWslSandboxError::Other(_) => {
|
||||
if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxWrap {
|
||||
|
|
@ -151,6 +176,15 @@ impl SandboxWrap {
|
|||
/// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical
|
||||
/// path) rather than passing a re-resolvable path. A location that can't be
|
||||
/// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed.
|
||||
///
|
||||
/// This function has **no filesystem side effects**: it never creates paths.
|
||||
/// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe
|
||||
/// and by real sandbox construction, and must behave identically. On Linux a
|
||||
/// writable grant that doesn't exist yet simply can't be captured (bwrap
|
||||
/// can't bind a missing path), so it's dropped here — the sanctioned way to
|
||||
/// get a grant to a new directory is the `create_directory` tool, which
|
||||
/// creates it (pinning the inode) before the grant is recorded. On macOS a
|
||||
/// missing leaf still canonicalizes, so such grants are captured directly.
|
||||
fn to_policy(&self) -> sandbox::SandboxPolicy {
|
||||
let protected_paths = self
|
||||
.protected_paths
|
||||
|
|
@ -164,12 +198,11 @@ impl SandboxWrap {
|
|||
.writable_paths
|
||||
.iter()
|
||||
.chain(self.extra_write_paths.iter())
|
||||
.filter_map(|path| {
|
||||
// Create not-yet-existing writable grants (e.g. an approved
|
||||
// scratch dir) so they can be captured and bound; best-effort.
|
||||
let _ = std::fs::create_dir_all(path);
|
||||
sandbox::HostFilesystemLocation::new(path).ok()
|
||||
})
|
||||
// Capture only — never create anything here (see the doc comment):
|
||||
// materializing an approved-but-missing grant is deferred to
|
||||
// `Sandbox::new` so it can never happen during the `can_create`
|
||||
// probe, before the user has approved the grant.
|
||||
.filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
|
||||
.collect();
|
||||
sandbox::SandboxFsPolicy::Restricted {
|
||||
writable_paths,
|
||||
|
|
|
|||
|
|
@ -281,7 +281,9 @@ impl DbThread {
|
|||
name: tool_use.name.into(),
|
||||
raw_input: serde_json::to_string(&tool_use.input)
|
||||
.unwrap_or_default(),
|
||||
input: tool_use.input,
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
tool_use.input,
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@
|
|||
//! caller see the same answer (and so the `target_os` gate lives in one
|
||||
//! place instead of scattered across the agent crate).
|
||||
//!
|
||||
//! The current policy is: enabled iff the user has the `sandboxing` feature
|
||||
//! flag turned on, the project is local, the platform has an integration, and
|
||||
//! the user has not persistently allowed unsandboxed execution (the
|
||||
//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed`
|
||||
//! The current policy is: enabled iff the project is local, the platform has an
|
||||
//! integration, and the user has not persistently allowed unsandboxed execution
|
||||
//! (the `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed`
|
||||
//! persistently turns sandboxing off for the model-facing surface entirely:
|
||||
//! the plain (non-sandboxed) `terminal` tool is exposed and the system prompt
|
||||
//! omits the sandbox section, since every command would run without a wrap
|
||||
|
|
@ -19,14 +18,12 @@
|
|||
//!
|
||||
//! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL)
|
||||
//! have real sandbox integrations; on platforms without one the per-command
|
||||
//! wrap is a no-op, so commands run with the agent's ambient permissions even
|
||||
//! when the flag is on.
|
||||
//! wrap is a no-op, so commands run with the agent's ambient permissions.
|
||||
//!
|
||||
//! Naming note: this module is about agent terminal sandboxing specifically.
|
||||
//! Other agent operations (e.g. file edits) are gated separately.
|
||||
|
||||
use agent_settings::{AgentSettings, SandboxPermissions};
|
||||
use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};
|
||||
use gpui::App;
|
||||
use http_proxy::HostPattern;
|
||||
use project::Project;
|
||||
|
|
@ -176,12 +173,6 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
|
|||
SandboxPolicy { fs, network }
|
||||
}
|
||||
|
||||
/// Whether agent-run terminal commands should be wrapped in an OS-level
|
||||
/// sandbox for this process. See module docs for the policy.
|
||||
pub(crate) fn sandboxing_enabled(cx: &App) -> bool {
|
||||
cx.has_flag::<SandboxingFeatureFlag>()
|
||||
}
|
||||
|
||||
/// Whether the sandboxed terminal can be exposed for this project.
|
||||
///
|
||||
/// The persistent `allow_unsandboxed` setting turns sandboxing off for the
|
||||
|
|
@ -193,20 +184,19 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool {
|
|||
/// prompt in place, since the model is still operating in the sandbox model and
|
||||
/// only escaping individual commands (tracked in `ThreadSandboxGrants`).
|
||||
pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool {
|
||||
sandboxing_available_for_project(project, cx)
|
||||
sandboxing_available_for_project(project)
|
||||
&& !AgentSettings::get_global(cx)
|
||||
.sandbox_permissions
|
||||
.allow_unsandboxed
|
||||
}
|
||||
|
||||
/// Whether sandboxing is *applicable* for this project at all — the feature is
|
||||
/// enabled, the project is local, and the platform has a sandbox integration —
|
||||
/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to
|
||||
/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from
|
||||
/// "sandboxing is available but turned off in settings" (show it, struck out).
|
||||
pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool {
|
||||
sandboxing_enabled(cx)
|
||||
&& project.is_local()
|
||||
/// Whether sandboxing is *applicable* for this project at all — the project is
|
||||
/// local and the platform has a sandbox integration — independent of the
|
||||
/// persistent `allow_unsandboxed` setting. Used by the UI to distinguish
|
||||
/// "sandboxing isn't relevant here" (don't show the indicator) from "sandboxing
|
||||
/// is available but turned off in settings" (show it, struck out).
|
||||
pub(crate) fn sandboxing_available_for_project(project: &Project) -> bool {
|
||||
project.is_local()
|
||||
&& cfg!(any(
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ You can request elevated permissions on individual `terminal` calls:
|
|||
- `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs.
|
||||
- `allow_all_hosts: true` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front.
|
||||
{{/if}}
|
||||
- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Git metadata paths cannot be requested and will never be made writable while sandboxed.
|
||||
- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Each path must be an existing directory. To write into a directory that doesn't exist yet, first create it with the `create_directory` tool (which creates it and grants write access to exactly that directory) rather than requesting write access to a broad existing parent. Git metadata paths cannot be requested and will never be made writable while sandboxed.
|
||||
- `allow_fs_write_all: true` — allow unrestricted filesystem writes except protected Git metadata. Only use this when the specific paths can't be enumerated up front.
|
||||
- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice, including when a command must write Git metadata.
|
||||
|
||||
|
|
|
|||
|
|
@ -689,7 +689,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) {
|
|||
id: "tool_1".into(),
|
||||
name: EchoTool::NAME.into(),
|
||||
raw_input: json!({"text": "test"}).to_string(),
|
||||
input: json!({"text": "test"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -840,17 +840,25 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
|
|||
assert_eq!(last_tool_use.name.as_ref(), "word_list");
|
||||
if tool_call.status == acp::ToolCallStatus::Pending {
|
||||
if !last_tool_use.is_input_complete
|
||||
&& last_tool_use.input.get("g").is_none()
|
||||
&& last_tool_use
|
||||
.input
|
||||
.as_json()
|
||||
.and_then(|input| input.get("g"))
|
||||
.is_none()
|
||||
{
|
||||
saw_partial_tool_use = true;
|
||||
}
|
||||
} else {
|
||||
last_tool_use
|
||||
.input
|
||||
.as_json()
|
||||
.expect("tool input should be JSON")
|
||||
.get("a")
|
||||
.expect("'a' has streamed because input is now complete");
|
||||
last_tool_use
|
||||
.input
|
||||
.as_json()
|
||||
.expect("tool input should be JSON")
|
||||
.get("g")
|
||||
.expect("'g' has streamed because input is now complete");
|
||||
}
|
||||
|
|
@ -884,7 +892,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
|
|||
id: "tool_id_1".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -894,7 +902,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
|
|||
id: "tool_id_2".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -951,7 +959,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
|
|||
id: "tool_id_3".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -990,7 +998,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
|
|||
id: "tool_id_4".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -1029,7 +1037,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) {
|
|||
id: "tool_id_1".into(),
|
||||
name: "nonexistent_tool".into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -1533,7 +1541,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
|
|||
id: "tool_1".into(),
|
||||
name: "echo".into(),
|
||||
raw_input: json!({"text": "test"}).to_string(),
|
||||
input: json!({"text": "test"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -1577,7 +1585,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
|
|||
id: "tool_2".into(),
|
||||
name: "test_server_echo".into(),
|
||||
raw_input: json!({"text": "mcp"}).to_string(),
|
||||
input: json!({"text": "mcp"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "mcp"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -1587,7 +1595,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
|
|||
id: "tool_3".into(),
|
||||
name: "echo".into(),
|
||||
raw_input: json!({"text": "native"}).to_string(),
|
||||
input: json!({"text": "native"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "native"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -1697,7 +1705,7 @@ async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext
|
|||
id: "tool_1".into(),
|
||||
name: "snake_case_PascalCase".into(),
|
||||
raw_input: json!({}).to_string(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -1786,7 +1794,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
|
|||
id: "tool_1".into(),
|
||||
name: "screenshot".into(),
|
||||
raw_input: json!({}).to_string(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -1936,7 +1944,9 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp
|
|||
name: "issue_read".into(),
|
||||
raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"})
|
||||
.to_string(),
|
||||
input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -2351,7 +2361,9 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext
|
|||
id: "terminal_tool_1".into(),
|
||||
name: TerminalTool::NAME.into(),
|
||||
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
|
||||
input: json!({"command": "sleep 1000", "cd": "."}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
json!({"command": "sleep 1000", "cd": "."}),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -2446,7 +2458,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC
|
|||
id: "cancellation_aware_1".into(),
|
||||
name: "cancellation_aware".into(),
|
||||
raw_input: r#"{}"#.into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -2632,7 +2644,9 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) {
|
|||
id: "terminal_tool_1".into(),
|
||||
name: TerminalTool::NAME.into(),
|
||||
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
|
||||
input: json!({"command": "sleep 1000", "cd": "."}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
json!({"command": "sleep 1000", "cd": "."}),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -2697,7 +2711,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext)
|
|||
id: "terminal_tool_1".into(),
|
||||
name: TerminalTool::NAME.into(),
|
||||
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
|
||||
input: json!({"command": "sleep 1000", "cd": "."}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
json!({"command": "sleep 1000", "cd": "."}),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -2707,7 +2723,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext)
|
|||
id: "terminal_tool_2".into(),
|
||||
name: TerminalTool::NAME.into(),
|
||||
raw_input: r#"{"command": "sleep 2000", "cd": "."}"#.into(),
|
||||
input: json!({"command": "sleep 2000", "cd": "."}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
json!({"command": "sleep 2000", "cd": "."}),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -2811,7 +2829,9 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon
|
|||
id: "terminal_tool_1".into(),
|
||||
name: TerminalTool::NAME.into(),
|
||||
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
|
||||
input: json!({"command": "sleep 1000", "cd": "."}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
json!({"command": "sleep 1000", "cd": "."}),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -2907,7 +2927,9 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) {
|
|||
id: "terminal_tool_1".into(),
|
||||
name: TerminalTool::NAME.into(),
|
||||
raw_input: r#"{"command": "sleep 1000", "cd": ".", "timeout_ms": 100}"#.into(),
|
||||
input: json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -3376,7 +3398,7 @@ async fn test_cumulative_token_usage(cx: &mut TestAppContext) {
|
|||
id: "tool_1".into(),
|
||||
name: EchoTool::NAME.into(),
|
||||
raw_input: json!({"text": "hello"}).to_string(),
|
||||
input: json!({"text": "hello"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -3786,7 +3808,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
|
|||
id: "tool_id_1".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -3794,7 +3816,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
|
|||
id: "tool_id_2".into(),
|
||||
name: EchoTool::NAME.into(),
|
||||
raw_input: json!({"text": "test"}).to_string(),
|
||||
input: json!({"text": "test"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -3992,7 +4014,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
|
|||
id: "1".into(),
|
||||
name: EchoTool::NAME.into(),
|
||||
raw_input: input.to_string(),
|
||||
input,
|
||||
input: language_model::LanguageModelToolUseInput::Json(input),
|
||||
is_input_complete: false,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -4005,7 +4027,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
|
|||
id: "1".into(),
|
||||
name: "echo".into(),
|
||||
raw_input: input.to_string(),
|
||||
input,
|
||||
input: language_model::LanguageModelToolUseInput::Json(input),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -4175,7 +4197,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
|
|||
id: "tool_1".into(),
|
||||
name: EchoTool::NAME.into(),
|
||||
raw_input: json!({"text": "test"}).to_string(),
|
||||
input: json!({"text": "test"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -4323,7 +4345,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input(
|
|||
id: "tool_1".into(),
|
||||
name: "streaming_echo".into(),
|
||||
raw_input: r#"{"text": "partial"}"#.into(),
|
||||
input: json!({"text": "partial"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
|
||||
is_input_complete: false,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -4428,7 +4450,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool(
|
|||
id: "tool_1".into(),
|
||||
name: StreamingJsonErrorContextTool::NAME.into(),
|
||||
raw_input: r#"{"text": "partial"#.into(),
|
||||
input: json!({"text": "partial"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
|
||||
is_input_complete: false,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -5228,7 +5250,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
|
|||
id: "subagent_1".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -5362,7 +5386,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon
|
|||
id: "subagent_1".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -5509,7 +5535,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp
|
|||
id: "subagent_1".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -5638,7 +5666,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
|
|||
id: "subagent_1".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -5699,7 +5729,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
|
|||
id: "subagent_2".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&resume_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&resume_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -6285,7 +6317,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
|
|||
id: "subagent_1".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -6410,7 +6444,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
|
|||
id: "subagent_1".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -6476,7 +6512,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
|
|||
id: "subagent_2".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&resume_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&resume_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -6583,7 +6621,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
|
|||
id: "subagent_1".into(),
|
||||
name: SpawnAgentTool::NAME.into(),
|
||||
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
|
||||
input: serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&subagent_tool_input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -7350,6 +7390,194 @@ async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext)
|
|||
);
|
||||
}
|
||||
|
||||
/// A granted host that redirects to a loopback target must not have that
|
||||
/// redirect followed: loopback hosts can't be granted individually, so the hop
|
||||
/// is refused just like a direct loopback fetch. This is the redirect variant of
|
||||
/// the SSRF protection — the approved domain can't be used to bounce the request
|
||||
/// onto the local machine.
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
settings
|
||||
.sandbox_permissions
|
||||
.network_hosts
|
||||
.push("example.com".into());
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
|
||||
let uri = req.uri().to_string();
|
||||
assert!(
|
||||
uri.contains("example.com"),
|
||||
"the loopback redirect target must never be requested, but saw {uri}"
|
||||
);
|
||||
Ok(gpui::http_client::Response::builder()
|
||||
.status(302)
|
||||
.header("location", "http://localhost:3000/internal")
|
||||
.body("".into())
|
||||
.unwrap())
|
||||
});
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::FetchTool::new(http_client));
|
||||
let (event_stream, _rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let input: crate::FetchToolInput =
|
||||
serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
|
||||
|
||||
let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
|
||||
let result = task.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected a redirect to a loopback host to be refused"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("unsandboxed"),
|
||||
"error should point at unsandboxed access as the way to reach loopback hosts"
|
||||
);
|
||||
}
|
||||
|
||||
/// A granted host that redirects to a *different*, ungranted host triggers a
|
||||
/// fresh per-host authorization prompt for the redirect target — the redirect is
|
||||
/// not silently followed to a host the user never approved.
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_reauthorizes_redirect_to_new_host(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
settings
|
||||
.sandbox_permissions
|
||||
.network_hosts
|
||||
.push("example.com".into());
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
|
||||
let uri = req.uri().to_string();
|
||||
assert!(
|
||||
uri.contains("example.com"),
|
||||
"the ungranted redirect target must not be requested before authorization, \
|
||||
but saw {uri}"
|
||||
);
|
||||
Ok(gpui::http_client::Response::builder()
|
||||
.status(302)
|
||||
.header("location", "https://redirect-target.example/landing")
|
||||
.body("".into())
|
||||
.unwrap())
|
||||
});
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::FetchTool::new(http_client));
|
||||
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let input: crate::FetchToolInput =
|
||||
serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
|
||||
|
||||
let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let authorization = rx.expect_authorization().await;
|
||||
let details =
|
||||
acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
|
||||
.expect("a redirect to an ungranted host should request a sandbox network grant");
|
||||
assert_eq!(
|
||||
details.network_hosts,
|
||||
vec!["redirect-target.example".to_string()]
|
||||
);
|
||||
assert!(!details.network_all_hosts);
|
||||
}
|
||||
|
||||
/// Redirects between paths on an already-granted host are followed without any
|
||||
/// additional prompt, so ordinary redirects (http→https upgrades, trailing-slash
|
||||
/// canonicalization, etc.) keep working after the per-hop authorization change.
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_follows_same_host_redirect(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
settings
|
||||
.sandbox_permissions
|
||||
.network_hosts
|
||||
.push("example.com".into());
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
|
||||
let uri = req.uri().to_string();
|
||||
if uri.ends_with("/start") {
|
||||
Ok(gpui::http_client::Response::builder()
|
||||
.status(302)
|
||||
.header("location", "https://example.com/final")
|
||||
.body("".into())
|
||||
.unwrap())
|
||||
} else if uri.ends_with("/final") {
|
||||
Ok(gpui::http_client::Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "text/plain")
|
||||
.body("final content".into())
|
||||
.unwrap())
|
||||
} else {
|
||||
panic!("unexpected request to {uri}");
|
||||
}
|
||||
});
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::FetchTool::new(http_client));
|
||||
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let input: crate::FetchToolInput =
|
||||
serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
|
||||
|
||||
let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
|
||||
let result = task.await;
|
||||
assert_eq!(
|
||||
result.expect("same-host redirect should succeed"),
|
||||
"final content"
|
||||
);
|
||||
|
||||
let event = rx.try_recv();
|
||||
assert!(
|
||||
!matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
|
||||
"expected no authorization prompt for a redirect to an already-granted host"
|
||||
);
|
||||
}
|
||||
|
||||
/// Approving one pending tool call with "Always for <tool>" auto-resolves
|
||||
/// sibling pending authorizations for the same tool in the same turn.
|
||||
#[gpui::test]
|
||||
|
|
@ -7372,7 +7600,7 @@ async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppConte
|
|||
id: id.into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -7451,7 +7679,7 @@ async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut Tes
|
|||
id: "tool_id_1".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -7522,7 +7750,7 @@ async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestApp
|
|||
id: "tool_id_1".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -7598,7 +7826,7 @@ async fn test_unrelated_settings_change_does_not_resolve_pending_authorization(
|
|||
id: "tool_id_1".into(),
|
||||
name: ToolRequiringPermission::NAME.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -7668,7 +7896,7 @@ async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mu
|
|||
id: id.into(),
|
||||
name: name.into(),
|
||||
raw_input: "{}".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -7765,7 +7993,7 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) {
|
|||
id: "tool_1".into(),
|
||||
name: "echo".into(),
|
||||
raw_input: r#"{"text": "hello"}"#.into(),
|
||||
input: json!({"text": "hello"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -7847,7 +8075,7 @@ async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut Te
|
|||
id: "tool_1".into(),
|
||||
name: "echo".into(),
|
||||
raw_input: r#"{"text": "hello"}"#.into(),
|
||||
input: json!({"text": "hello"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -7914,7 +8142,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA
|
|||
id: "call_1".into(),
|
||||
name: StreamingFailingEchoTool::NAME.into(),
|
||||
raw_input: "hello".into(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: false,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -7996,7 +8224,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
|
|||
id: "call_1".into(),
|
||||
name: StreamingEchoTool::NAME.into(),
|
||||
raw_input: "hello".into(),
|
||||
input: json!({ "text": "hello" }),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
|
||||
is_input_complete: false,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -8005,7 +8233,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
|
|||
id: "call_1".into(),
|
||||
name: StreamingEchoTool::NAME.into(),
|
||||
raw_input: "hello world".into(),
|
||||
input: json!({ "text": "hello world" }),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello world" })),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -8015,7 +8243,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
|
|||
let second_tool_use = LanguageModelToolUse {
|
||||
name: StreamingFailingEchoTool::NAME.into(),
|
||||
raw_input: "hello".into(),
|
||||
input: json!({ "text": "hello" }),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
|
||||
is_input_complete: false,
|
||||
thought_signature: None,
|
||||
id: "call_2".into(),
|
||||
|
|
@ -8144,7 +8372,7 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) {
|
|||
id: "tool_1".into(),
|
||||
name: "echo".into(),
|
||||
raw_input: r#"{"text":"hello"}"#.into(),
|
||||
input: json!({"text": "hello"}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -607,7 +607,7 @@ impl AgentMessage {
|
|||
"{}\n",
|
||||
MarkdownCodeBlock {
|
||||
tag: "json",
|
||||
text: &format!("{:#}", tool_use.input)
|
||||
text: &format!("{:#}", tool_use.input.to_display_json())
|
||||
}
|
||||
));
|
||||
}
|
||||
|
|
@ -1593,7 +1593,7 @@ impl Thread {
|
|||
.unbounded_send(Ok(ThreadEvent::ToolCall(
|
||||
acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string())
|
||||
.status(status)
|
||||
.raw_input(tool_use.input.clone()),
|
||||
.raw_input(tool_use.input.to_display_json()),
|
||||
)))
|
||||
.ok();
|
||||
let mut fields = acp::ToolCallUpdateFields::new()
|
||||
|
|
@ -1606,15 +1606,12 @@ impl Thread {
|
|||
return;
|
||||
};
|
||||
|
||||
let title = tool.initial_title(tool_use.input.clone(), cx);
|
||||
let Ok(input) = tool_use.input.clone().into_json() else {
|
||||
return;
|
||||
};
|
||||
let title = tool.initial_title(input.clone(), cx);
|
||||
let kind = tool.kind();
|
||||
stream.send_tool_call(
|
||||
&tool_use.id,
|
||||
&tool_use.name,
|
||||
title,
|
||||
kind,
|
||||
tool_use.input.clone(),
|
||||
);
|
||||
stream.send_tool_call(&tool_use.id, &tool_use.name, title, kind, input.clone());
|
||||
|
||||
if let Some(content) = replay_content {
|
||||
stream.update_tool_call_fields(
|
||||
|
|
@ -1635,8 +1632,7 @@ impl Thread {
|
|||
self.sandbox_grants.clone(),
|
||||
Some(cx.weak_entity()),
|
||||
);
|
||||
tool.replay(tool_use.input.clone(), output, tool_event_stream, cx)
|
||||
.log_err();
|
||||
tool.replay(input, output, tool_event_stream, cx).log_err();
|
||||
}
|
||||
|
||||
stream.update_tool_call_fields(
|
||||
|
|
@ -1850,12 +1846,12 @@ impl Thread {
|
|||
sandboxing_enabled_for_project(self.project.read(cx), cx)
|
||||
}
|
||||
|
||||
/// Whether sandboxing is *applicable* for this thread's project (feature on,
|
||||
/// local project, supported platform), regardless of whether it's been
|
||||
/// turned off in settings. The UI shows the sandbox indicator whenever this
|
||||
/// is true, drawing it struck-out when sandboxing is disabled.
|
||||
/// Whether sandboxing is *applicable* for this thread's project (local
|
||||
/// project, supported platform), regardless of whether it's been turned off
|
||||
/// in settings. The UI shows the sandbox indicator whenever this is true,
|
||||
/// drawing it struck-out when sandboxing is disabled.
|
||||
pub fn sandboxing_available(&self, cx: &App) -> bool {
|
||||
sandboxing_available_for_project(self.project.read(cx), cx)
|
||||
sandboxing_available_for_project(self.project.read(cx))
|
||||
}
|
||||
|
||||
/// The directory subtrees the sandbox always grants write access to for this
|
||||
|
|
@ -3418,7 +3414,9 @@ impl Thread {
|
|||
let mut title = SharedString::from(&tool_use.name);
|
||||
let mut kind = acp::ToolKind::Other;
|
||||
if let Some(tool) = tool.as_ref() {
|
||||
title = tool.initial_title(tool_use.input.clone(), cx);
|
||||
if let Ok(input) = tool_use.input.clone().into_json() {
|
||||
title = tool.initial_title(input, cx);
|
||||
}
|
||||
kind = tool.kind();
|
||||
}
|
||||
|
||||
|
|
@ -3435,16 +3433,33 @@ impl Thread {
|
|||
}));
|
||||
};
|
||||
|
||||
// Agent tools are JSON-schema tools. Custom text-tool deltas are rejected
|
||||
// before considering partial-vs-complete input for these local tools.
|
||||
let input = match tool_use.input.clone().into_json() {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
return Some(Task::ready(LanguageModelToolResult {
|
||||
content: vec![LanguageModelToolResultContent::Text(Arc::from(
|
||||
error.to_string(),
|
||||
))],
|
||||
tool_use_id: tool_use.id,
|
||||
tool_name: tool_use.name,
|
||||
is_error: true,
|
||||
output: None,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
if !tool_use.is_input_complete {
|
||||
if tool.supports_input_streaming() {
|
||||
let running_turn = self.running_turn.as_mut()?;
|
||||
if let Some(sender) = running_turn.streaming_tool_inputs.get_mut(&tool_use.id) {
|
||||
sender.send_partial(tool_use.input);
|
||||
sender.send_partial(input);
|
||||
return None;
|
||||
}
|
||||
|
||||
let (mut sender, tool_input) = ToolInputSender::channel();
|
||||
sender.send_partial(tool_use.input);
|
||||
sender.send_partial(input);
|
||||
running_turn
|
||||
.streaming_tool_inputs
|
||||
.insert(tool_use.id.clone(), sender);
|
||||
|
|
@ -3471,12 +3486,12 @@ impl Thread {
|
|||
.streaming_tool_inputs
|
||||
.remove(&tool_use.id)
|
||||
{
|
||||
sender.send_full(tool_use.input);
|
||||
sender.send_full(input);
|
||||
return None;
|
||||
}
|
||||
|
||||
log::debug!("Running tool {}", tool_use.name);
|
||||
let tool_input = ToolInput::ready(tool_use.input);
|
||||
let tool_input = ToolInput::ready(input);
|
||||
Some(self.run_tool(
|
||||
tool,
|
||||
tool_input,
|
||||
|
|
@ -3600,7 +3615,7 @@ impl Thread {
|
|||
id: tool_use_id,
|
||||
name: tool_name,
|
||||
raw_input: raw_input.to_string(),
|
||||
input: serde_json::json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(serde_json::json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -3676,7 +3691,7 @@ impl Thread {
|
|||
&tool_use.name,
|
||||
title,
|
||||
kind,
|
||||
tool_use.input.clone(),
|
||||
tool_use.input.to_display_json(),
|
||||
);
|
||||
last_message
|
||||
.content
|
||||
|
|
@ -3687,7 +3702,7 @@ impl Thread {
|
|||
acp::ToolCallUpdateFields::new()
|
||||
.title(title.as_str())
|
||||
.kind(kind)
|
||||
.raw_input(tool_use.input.clone()),
|
||||
.raw_input(tool_use.input.to_display_json()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
|
@ -3927,12 +3942,12 @@ impl Thread {
|
|||
.iter()
|
||||
.filter_map(|(tool_name, tool)| {
|
||||
log::trace!("Including tool: {}", tool_name);
|
||||
Some(LanguageModelRequestTool {
|
||||
name: tool_name.to_string(),
|
||||
description: tool.description().to_string(),
|
||||
input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
|
||||
use_input_streaming: tool.supports_input_streaming(),
|
||||
})
|
||||
Some(LanguageModelRequestTool::function(
|
||||
tool_name.to_string(),
|
||||
tool.description().to_string(),
|
||||
tool.input_schema(model.tool_input_format()).log_err()?,
|
||||
tool.supports_input_streaming(),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
|
|
@ -5911,10 +5926,15 @@ impl ToolCallEventStream {
|
|||
&self,
|
||||
command: Option<String>,
|
||||
reason: String,
|
||||
docs_section: Option<String>,
|
||||
retries: usize,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<SandboxFallbackDecision>> {
|
||||
let details = acp_thread::SandboxFallbackAuthorizationDetails { command, reason };
|
||||
let details = acp_thread::SandboxFallbackAuthorizationDetails {
|
||||
command,
|
||||
reason,
|
||||
docs_section,
|
||||
};
|
||||
let retry_label = if retries == 0 {
|
||||
"Retry".to_string()
|
||||
} else {
|
||||
|
|
@ -7671,6 +7691,7 @@ mod tests {
|
|||
event_stream.authorize_sandbox_fallback(
|
||||
Some("cargo build".to_string()),
|
||||
"bwrap not found on PATH".to_string(),
|
||||
Some("installing-bubblewrap".to_string()),
|
||||
0,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -7682,6 +7703,10 @@ mod tests {
|
|||
.expect("fallback authorization should include details");
|
||||
assert_eq!(details.command.as_deref(), Some("cargo build"));
|
||||
assert_eq!(details.reason, "bwrap not found on PATH");
|
||||
assert_eq!(
|
||||
details.docs_section.as_deref(),
|
||||
Some("installing-bubblewrap")
|
||||
);
|
||||
|
||||
let acp_thread::PermissionOptions::Flat(options) = &authorization.options else {
|
||||
panic!("expected flat fallback permission options");
|
||||
|
|
@ -7722,6 +7747,7 @@ mod tests {
|
|||
event_stream.authorize_sandbox_fallback(
|
||||
None,
|
||||
"probe failed".to_string(),
|
||||
None,
|
||||
retries,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -7766,6 +7792,7 @@ mod tests {
|
|||
event_stream.authorize_sandbox_fallback(
|
||||
Some("cargo build".to_string()),
|
||||
"user namespaces are disabled".to_string(),
|
||||
None,
|
||||
0,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -7795,7 +7822,13 @@ mod tests {
|
|||
|
||||
let (event_stream, mut receiver) = ToolCallEventStream::test();
|
||||
let authorize = cx.update(|cx| {
|
||||
event_stream.authorize_sandbox_fallback(None, "bwrap probe failed".to_string(), 0, cx)
|
||||
event_stream.authorize_sandbox_fallback(
|
||||
None,
|
||||
"bwrap probe failed".to_string(),
|
||||
None,
|
||||
0,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let authorization = receiver.expect_authorization().await;
|
||||
authorization
|
||||
|
|
@ -7870,7 +7903,7 @@ mod tests {
|
|||
id: registered_tool_use_id.clone(),
|
||||
name: ReplayImageTool::NAME.into(),
|
||||
raw_input: "null".to_string(),
|
||||
input: json!(null),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!(null)),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -7878,7 +7911,7 @@ mod tests {
|
|||
id: missing_tool_use_id.clone(),
|
||||
name: "missing_image_tool".into(),
|
||||
raw_input: "{}".to_string(),
|
||||
input: json!({}),
|
||||
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
};
|
||||
|
|
@ -8185,7 +8218,10 @@ mod tests {
|
|||
assert_eq!(tool_use.raw_input, raw_input.to_string());
|
||||
assert!(tool_use.is_input_complete);
|
||||
// Should fall back to empty object for invalid JSON
|
||||
assert_eq!(tool_use.input, json!({}));
|
||||
assert_eq!(
|
||||
tool_use.input,
|
||||
language_model::LanguageModelToolUseInput::Json(json!({}))
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected ToolUse content"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,12 +153,12 @@ macro_rules! tools {
|
|||
/// A list of all built-in tools
|
||||
pub fn built_in_tools() -> impl Iterator<Item = LanguageModelRequestTool> {
|
||||
fn language_model_tool<T: AgentTool>() -> LanguageModelRequestTool {
|
||||
LanguageModelRequestTool {
|
||||
name: T::NAME.to_string(),
|
||||
description: T::description().to_string(),
|
||||
input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(),
|
||||
use_input_streaming: T::supports_input_streaming(),
|
||||
}
|
||||
LanguageModelRequestTool::function(
|
||||
T::NAME.to_string(),
|
||||
T::description().to_string(),
|
||||
T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(),
|
||||
T::supports_input_streaming(),
|
||||
)
|
||||
}
|
||||
[
|
||||
$(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use super::tool_permissions::{
|
|||
use agent_client_protocol::schema::v1 as acp;
|
||||
use agent_settings::AgentSettings;
|
||||
use futures::FutureExt as _;
|
||||
use gpui::{App, Entity, SharedString, Task};
|
||||
use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task};
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -17,12 +17,13 @@ use crate::{
|
|||
AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
|
||||
authorize_with_sensitive_settings, decide_permission_for_path,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.
|
||||
/// Creates a new directory at the specified path, and all necessary parent directories. Returns confirmation that the directory was created.
|
||||
///
|
||||
/// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project.
|
||||
/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
|
||||
/// Use this whenever you need to create new directories. Paths inside the project are created directly.
|
||||
///
|
||||
/// This tool can also create a directory **outside** the project. When agent terminal commands are sandboxed, doing so grants those commands write access to exactly that new directory — so, rather than requesting write access to a broad existing parent (e.g. your home directory) just to create something inside it, create the specific directory here first and then write into it. The only other supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CreateDirectoryToolInput {
|
||||
/// The path of the new directory.
|
||||
|
|
@ -40,6 +41,13 @@ pub struct CreateDirectoryToolInput {
|
|||
/// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`.
|
||||
/// </example>
|
||||
pub path: String,
|
||||
|
||||
/// Justification for creating a directory **outside** the project, shown to
|
||||
/// the user (attributed to you) in the approval prompt that grants sandboxed
|
||||
/// terminal commands write access to it. Required only for out-of-project
|
||||
/// paths; ignored for paths inside the project or the global skills dir.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CreateDirectoryTool {
|
||||
|
|
@ -83,6 +91,28 @@ impl AgentTool for CreateDirectoryTool {
|
|||
let project = self.project.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
let input = input.recv().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
|
||||
|
||||
// Resolve where this directory lives. The global agent-skills dir is a
|
||||
// special case allowed outside the project; anything else outside the
|
||||
// project is handled as a narrow sandbox write grant below.
|
||||
let global_skill_directory =
|
||||
resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await;
|
||||
let in_project = project.read_with(cx, |project, cx| {
|
||||
project.find_project_path(&input.path, cx).is_some()
|
||||
});
|
||||
|
||||
// A path outside the project (and not the global skills dir) can only
|
||||
// be created as a narrow sandbox write grant: create the directory and
|
||||
// grant sandboxed terminal commands write access to exactly it. The
|
||||
// sandbox approval prompt — which shows the real, canonicalized target
|
||||
// — fully replaces the normal permission and symlink-escape prompts
|
||||
// here.
|
||||
if global_skill_directory.is_none() && !in_project {
|
||||
return create_out_of_project_directory(&project, &input, &event_stream, cx).await;
|
||||
}
|
||||
|
||||
let decision = cx.update(|cx| {
|
||||
decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx))
|
||||
});
|
||||
|
|
@ -93,7 +123,6 @@ impl AgentTool for CreateDirectoryTool {
|
|||
|
||||
let destination_path: Arc<str> = input.path.as_str().into();
|
||||
|
||||
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
|
||||
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
|
||||
|
||||
let symlink_escape_target = project.read_with(cx, |project, cx| {
|
||||
|
|
@ -149,9 +178,7 @@ impl AgentTool for CreateDirectoryTool {
|
|||
authorize.await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if let Some(global_skill_directory) =
|
||||
resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await
|
||||
{
|
||||
if let Some(global_skill_directory) = global_skill_directory {
|
||||
futures::select! {
|
||||
result = fs.create_dir(&global_skill_directory).fuse() => {
|
||||
result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?;
|
||||
|
|
@ -185,6 +212,100 @@ impl AgentTool for CreateDirectoryTool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a directory that lives **outside** the project by granting sandboxed
|
||||
/// terminal commands write access to exactly it.
|
||||
///
|
||||
/// The directory is created (Linux: eagerly, pinning the inode; macOS: after
|
||||
/// approval) and the user is shown the real, canonicalized target in the sandbox
|
||||
/// approval prompt — which is what defends against a concurrent symlink swap: the
|
||||
/// grant is always against the inode/path the user actually saw. On denial, only
|
||||
/// the directories we created are removed.
|
||||
async fn create_out_of_project_directory(
|
||||
project: &Entity<Project>,
|
||||
input: &CreateDirectoryToolInput,
|
||||
event_stream: &ToolCallEventStream,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<String, String> {
|
||||
// Narrowing a grant to a brand-new directory only makes sense when the
|
||||
// project's terminal commands are sandboxed, and only on platforms that can
|
||||
// grant a not-yet-existing directory. Otherwise keep the historical
|
||||
// "outside the project" rejection.
|
||||
let sandboxing = project.read_with(cx, |project, cx| {
|
||||
crate::sandboxing::sandboxing_enabled_for_project(project, cx)
|
||||
});
|
||||
let platform_supported = cfg!(any(target_os = "linux", target_os = "macos"));
|
||||
if !sandboxing || !platform_supported {
|
||||
return Err("Path to create was outside the project".to_string());
|
||||
}
|
||||
|
||||
let Some(reason) = input
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|reason| !reason.is_empty())
|
||||
else {
|
||||
return Err(
|
||||
"Creating a directory outside the project grants sandboxed terminal commands write \
|
||||
access to it, so a `reason` is required: briefly justify why the directory is needed, \
|
||||
then try again."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let reason = reason.to_string();
|
||||
|
||||
let absolute = resolve_absolute_path(project, &input.path, cx)
|
||||
.ok_or_else(|| format!("Couldn't resolve `{}` to an absolute path.", input.path))?;
|
||||
|
||||
let prepared = cx
|
||||
.background_spawn(async move { sandbox::GrantableWriteDir::prepare(&absolute) })
|
||||
.await
|
||||
.map_err(|error| format!("Creating directory {}: {error}", input.path))?;
|
||||
|
||||
let canonical = prepared.canonical_path().to_path_buf();
|
||||
let request = crate::sandboxing::SandboxRequest {
|
||||
write_paths: vec![canonical.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let approve = cx.update(|cx| event_stream.authorize_sandbox(request, reason, cx));
|
||||
match approve.await {
|
||||
Ok(()) => {
|
||||
let display = canonical.display().to_string();
|
||||
cx.background_spawn(async move { prepared.finalize() })
|
||||
.await
|
||||
.map_err(|error| format!("Creating directory {display}: {error}"))?;
|
||||
Ok(format!("Created directory {display}"))
|
||||
}
|
||||
Err(error) => {
|
||||
// Roll back exactly what we created; leave the user no litter.
|
||||
cx.background_spawn(async move { prepared.discard() }).await;
|
||||
Err(format!("Create directory cancelled: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a model-provided path to an absolute, lexically-normalized path.
|
||||
/// Relative paths are joined onto the first worktree root.
|
||||
fn resolve_absolute_path(
|
||||
project: &Entity<Project>,
|
||||
raw: &str,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Option<PathBuf> {
|
||||
let path = Path::new(raw);
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
let base = project.read_with(cx, |project, cx| {
|
||||
project
|
||||
.worktrees(cx)
|
||||
.next()
|
||||
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
|
||||
})?;
|
||||
base.join(path)
|
||||
};
|
||||
util::paths::normalize_lexically(&absolute).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -231,7 +352,10 @@ mod tests {
|
|||
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput { path: input_path }),
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: input_path,
|
||||
reason: None,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -286,6 +410,7 @@ mod tests {
|
|||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: outside_path.to_string_lossy().into_owned(),
|
||||
reason: None,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -342,6 +467,7 @@ mod tests {
|
|||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: "project/link_to_external".into(),
|
||||
reason: None,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -404,6 +530,7 @@ mod tests {
|
|||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: "project/link_to_external".into(),
|
||||
reason: None,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -463,6 +590,7 @@ mod tests {
|
|||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: "project/link_to_external".into(),
|
||||
reason: None,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -545,6 +673,7 @@ mod tests {
|
|||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: "project/link_to_external".into(),
|
||||
reason: None,
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -561,4 +690,107 @@ mod tests {
|
|||
"Deny policy should not emit symlink authorization prompt",
|
||||
);
|
||||
}
|
||||
|
||||
/// Out-of-project creation goes through the sandbox write-grant prompt and,
|
||||
/// on approval, creates the *specific* new directory (not its broad parent).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[gpui::test]
|
||||
async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } }))
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
// The sandbox create path operates on the *real* filesystem, so use a
|
||||
// real directory outside the (fake) project.
|
||||
let scratch = tempfile::tempdir().unwrap();
|
||||
let target = scratch.path().join("new_grant_dir");
|
||||
assert!(!target.exists());
|
||||
|
||||
let tool = Arc::new(CreateDirectoryTool::new(project));
|
||||
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
||||
let path_input = target.to_string_lossy().into_owned();
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: path_input,
|
||||
reason: Some("scratch space for the build".into()),
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let auth = event_rx.expect_authorization().await;
|
||||
let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta)
|
||||
.expect("out-of-project create should request a sandbox write grant");
|
||||
// The grant is for exactly the new directory, not its parent.
|
||||
assert_eq!(
|
||||
details.write_paths,
|
||||
vec![scratch.path().canonicalize().unwrap().join("new_grant_dir")]
|
||||
);
|
||||
|
||||
auth.response
|
||||
.send(acp_thread::SelectedPermissionOutcome::new(
|
||||
acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()),
|
||||
acp::PermissionOptionKind::AllowAlways,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let result = task.await;
|
||||
assert!(result.is_ok(), "expected success, got {result:?}");
|
||||
assert!(
|
||||
target.is_dir(),
|
||||
"the new directory should have been created"
|
||||
);
|
||||
}
|
||||
|
||||
/// Denying the grant removes the directory we eagerly created, leaving no
|
||||
/// trace on the filesystem.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[gpui::test]
|
||||
async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } }))
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let scratch = tempfile::tempdir().unwrap();
|
||||
let target = scratch.path().join("denied_dir");
|
||||
|
||||
let tool = Arc::new(CreateDirectoryTool::new(project));
|
||||
let (event_stream, mut event_rx) = ToolCallEventStream::test();
|
||||
let path_input = target.to_string_lossy().into_owned();
|
||||
let task = cx.update(|cx| {
|
||||
tool.run(
|
||||
ToolInput::resolved(CreateDirectoryToolInput {
|
||||
path: path_input,
|
||||
reason: Some("scratch space".into()),
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let auth = event_rx.expect_authorization().await;
|
||||
auth.response
|
||||
.send(acp_thread::SelectedPermissionOutcome::new(
|
||||
acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()),
|
||||
acp::PermissionOptionKind::RejectOnce,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let result = task.await;
|
||||
assert!(result.is_err(), "denied create should fail");
|
||||
assert!(
|
||||
!target.exists(),
|
||||
"denied create should leave no directory behind"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -324,6 +324,77 @@ mod tests {
|
|||
assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_streaming_edit_first_line_missing_indent(cx: &mut TestAppContext) {
|
||||
// Reproduces https://github.com/zed-industries/zed/issues/60302: the
|
||||
// first line of the multi-line `old_text` omits its leading
|
||||
// indentation while subsequent lines include theirs, so the indent
|
||||
// delta computed from the first line must not be applied to the
|
||||
// following lines. `old_text` also omits the `self.extra` line, so
|
||||
// the query lines don't correspond one-to-one to the matched buffer
|
||||
// rows and the indent pairing must follow the fuzzy match's
|
||||
// alignment instead of assuming equal line counts.
|
||||
let content = concat!(
|
||||
"class Outer:\n",
|
||||
" def method(self):\n",
|
||||
" self.kept = \"unchanged\"\n",
|
||||
" self.target_a = \"before\"\n",
|
||||
" self.extra = \"row\"\n",
|
||||
" self.target_b = \"before\"\n",
|
||||
" self.target_c = \"before\"\n",
|
||||
" self.target_d = \"before\"\n",
|
||||
" self.kept_2 = \"unchanged\"\n",
|
||||
);
|
||||
let (edit_tool, _project, _action_log, _fs, _thread) =
|
||||
setup_test(cx, json!({"file.py": content})).await;
|
||||
let result = cx
|
||||
.update(|cx| {
|
||||
edit_tool.clone().run(
|
||||
ToolInput::resolved(EditFileToolInput {
|
||||
path: "root/file.py".into(),
|
||||
edits: vec![Edit {
|
||||
old_text: concat!(
|
||||
"self.target_a = \"before\"\n",
|
||||
" self.target_b = \"before\"\n",
|
||||
" self.target_c = \"before\"\n",
|
||||
" self.target_d = \"before\"",
|
||||
)
|
||||
.into(),
|
||||
new_text: concat!(
|
||||
"self.target_a = \"after\"\n",
|
||||
" self.target_b = \"after\"\n",
|
||||
" self.target_c = \"after\"\n",
|
||||
" self.target_d = \"after\"",
|
||||
)
|
||||
.into(),
|
||||
}],
|
||||
}),
|
||||
ToolCallEventStream::test().0,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
|
||||
panic!("expected success");
|
||||
};
|
||||
// The matched range includes the `self.extra` row, so it is replaced
|
||||
// along with the rest of the match.
|
||||
assert_eq!(
|
||||
new_text,
|
||||
concat!(
|
||||
"class Outer:\n",
|
||||
" def method(self):\n",
|
||||
" self.kept = \"unchanged\"\n",
|
||||
" self.target_a = \"after\"\n",
|
||||
" self.target_b = \"after\"\n",
|
||||
" self.target_c = \"after\"\n",
|
||||
" self.target_d = \"after\"\n",
|
||||
" self.kept_2 = \"unchanged\"\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) {
|
||||
let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry};
|
|||
use language_model::LanguageModelToolResultContent;
|
||||
use project::lsp_store::{FormatTrigger, LspFormatTarget};
|
||||
use project::{AgentLocation, Project, ProjectPath};
|
||||
use reindent::{Reindenter, compute_indent_delta};
|
||||
use reindent::{Reindenter, compute_indent_delta, compute_rest_indent_delta};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use std::ops::Range;
|
||||
|
|
@ -527,15 +527,34 @@ impl EditPipeline {
|
|||
);
|
||||
|
||||
let buffer_indent = snapshot.line_indent_for_row(line);
|
||||
let query_lines = matcher.query_lines();
|
||||
let query_indent = text::LineIndent::from_iter(
|
||||
matcher
|
||||
.query_lines()
|
||||
query_lines
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.chars(),
|
||||
);
|
||||
let indent_delta = compute_indent_delta(buffer_indent, query_indent);
|
||||
let first_line_delta = compute_indent_delta(buffer_indent, query_indent);
|
||||
|
||||
// Query row 0 is excluded: its delta is `first_line_delta`,
|
||||
// which intentionally differs when the model stripped the
|
||||
// first line's indentation.
|
||||
let rest_delta = compute_rest_indent_delta(
|
||||
first_line_delta,
|
||||
matcher
|
||||
.line_pairs(&range)
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.filter(|(query_row, _)| *query_row != 0)
|
||||
.filter_map(|(query_row, buffer_row)| {
|
||||
let query_line = query_lines.get(*query_row as usize)?;
|
||||
Some((
|
||||
snapshot.line_indent_for_row(*buffer_row),
|
||||
text::LineIndent::from_iter(query_line.chars()),
|
||||
))
|
||||
}),
|
||||
);
|
||||
|
||||
let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::<String>();
|
||||
|
||||
|
|
@ -551,7 +570,7 @@ impl EditPipeline {
|
|||
self.current_edit = Some(EditPipelineEntry::StreamingNewText {
|
||||
streaming_diff: StreamingDiff::new(old_text_in_buffer),
|
||||
edit_cursor: range.start,
|
||||
reindenter: Reindenter::new(indent_delta),
|
||||
reindenter: Reindenter::with_deltas(first_line_delta, rest_delta),
|
||||
original_snapshot: text_snapshot,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use language::LineIndent;
|
||||
use std::{cmp, iter};
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum IndentDelta {
|
||||
Spaces(isize),
|
||||
Tabs(isize),
|
||||
|
|
@ -31,20 +31,60 @@ pub fn compute_indent_delta(buffer_indent: LineIndent, query_indent: LineIndent)
|
|||
}
|
||||
}
|
||||
|
||||
/// Computes the indent delta for the lines after the first, given per-line
|
||||
/// `(buffer, query)` indents for those lines.
|
||||
///
|
||||
/// When the remaining lines agree on a consistent delta, that delta is
|
||||
/// returned even if it differs from `first_line_delta`. This handles queries
|
||||
/// where only the first line's indentation was stripped. When the remaining
|
||||
/// lines are inconsistent (or all blank), falls back to `first_line_delta`,
|
||||
/// preserving the uniform re-indentation behavior.
|
||||
pub fn compute_rest_indent_delta(
|
||||
first_line_delta: IndentDelta,
|
||||
indent_pairs: impl IntoIterator<Item = (LineIndent, LineIndent)>,
|
||||
) -> IndentDelta {
|
||||
let mut rest_delta = None;
|
||||
for (buffer_indent, query_indent) in indent_pairs {
|
||||
if buffer_indent.line_blank || query_indent.line_blank {
|
||||
continue;
|
||||
}
|
||||
let delta = compute_indent_delta(buffer_indent, query_indent);
|
||||
match rest_delta {
|
||||
None => rest_delta = Some(delta),
|
||||
Some(existing) if existing == delta => {}
|
||||
Some(_) => return first_line_delta,
|
||||
}
|
||||
}
|
||||
rest_delta.unwrap_or(first_line_delta)
|
||||
}
|
||||
|
||||
/// Synchronous re-indentation adapter. Buffers incomplete lines and applies
|
||||
/// an `IndentDelta` to each line's leading whitespace before emitting it.
|
||||
///
|
||||
/// Models sometimes omit the leading indentation only on the first line of
|
||||
/// `old_text`/`new_text` (e.g. when copying from mid-line context), so the
|
||||
/// first line and the remaining lines can require different deltas.
|
||||
pub struct Reindenter {
|
||||
delta: IndentDelta,
|
||||
first_line_delta: IndentDelta,
|
||||
rest_delta: IndentDelta,
|
||||
buffer: String,
|
||||
in_leading_whitespace: bool,
|
||||
on_first_line: bool,
|
||||
}
|
||||
|
||||
impl Reindenter {
|
||||
pub fn new(delta: IndentDelta) -> Self {
|
||||
#[cfg(test)]
|
||||
fn uniform(delta: IndentDelta) -> Self {
|
||||
Self::with_deltas(delta, delta)
|
||||
}
|
||||
|
||||
pub fn with_deltas(first_line_delta: IndentDelta, rest_delta: IndentDelta) -> Self {
|
||||
Self {
|
||||
delta,
|
||||
first_line_delta,
|
||||
rest_delta,
|
||||
buffer: String::new(),
|
||||
in_leading_whitespace: true,
|
||||
on_first_line: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,14 +110,19 @@ impl Reindenter {
|
|||
None => (self.buffer.len(), true),
|
||||
};
|
||||
let line = &self.buffer[start_ix..line_end];
|
||||
let delta = if self.on_first_line {
|
||||
self.first_line_delta
|
||||
} else {
|
||||
self.rest_delta
|
||||
};
|
||||
|
||||
if self.in_leading_whitespace {
|
||||
if let Some(non_whitespace_ix) = line.find(|c| self.delta.character() != c) {
|
||||
if let Some(non_whitespace_ix) = line.find(|c| delta.character() != c) {
|
||||
// We found a non-whitespace character, adjust indentation
|
||||
// based on the delta.
|
||||
let new_indent_len =
|
||||
cmp::max(0, non_whitespace_ix as isize + self.delta.len()) as usize;
|
||||
indented.extend(iter::repeat(self.delta.character()).take(new_indent_len));
|
||||
cmp::max(0, non_whitespace_ix as isize + delta.len()) as usize;
|
||||
indented.extend(iter::repeat(delta.character()).take(new_indent_len));
|
||||
indented.push_str(&line[non_whitespace_ix..]);
|
||||
self.in_leading_whitespace = false;
|
||||
} else if is_pending_line && !is_final {
|
||||
|
|
@ -97,6 +142,7 @@ impl Reindenter {
|
|||
break;
|
||||
} else {
|
||||
self.in_leading_whitespace = true;
|
||||
self.on_first_line = false;
|
||||
indented.push('\n');
|
||||
start_ix = line_end + 1;
|
||||
}
|
||||
|
|
@ -116,7 +162,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_indent_single_chunk() {
|
||||
let mut r = Reindenter::new(IndentDelta::Spaces(2));
|
||||
let mut r = Reindenter::uniform(IndentDelta::Spaces(2));
|
||||
let out = r.push(" abc\n def\n ghi");
|
||||
// All three lines are emitted: "ghi" starts with spaces but
|
||||
// contains non-whitespace, so it's processed immediately.
|
||||
|
|
@ -127,7 +173,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_outdent_tabs() {
|
||||
let mut r = Reindenter::new(IndentDelta::Tabs(-2));
|
||||
let mut r = Reindenter::uniform(IndentDelta::Tabs(-2));
|
||||
let out = r.push("\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi");
|
||||
assert_eq!(out, "\t\tabc\ndef\n\t\t\t\tghi");
|
||||
let out = r.finish();
|
||||
|
|
@ -136,7 +182,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_incremental_chunks() {
|
||||
let mut r = Reindenter::new(IndentDelta::Spaces(2));
|
||||
let mut r = Reindenter::uniform(IndentDelta::Spaces(2));
|
||||
// Feed " ab" — the `a` is non-whitespace, so the line is
|
||||
// processed immediately even without a trailing newline.
|
||||
let out = r.push(" ab");
|
||||
|
|
@ -151,7 +197,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_zero_delta() {
|
||||
let mut r = Reindenter::new(IndentDelta::Spaces(0));
|
||||
let mut r = Reindenter::uniform(IndentDelta::Spaces(0));
|
||||
let out = r.push(" hello\n world\n");
|
||||
assert_eq!(out, " hello\n world\n");
|
||||
let out = r.finish();
|
||||
|
|
@ -160,7 +206,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_clamp_negative_indent() {
|
||||
let mut r = Reindenter::new(IndentDelta::Spaces(-10));
|
||||
let mut r = Reindenter::uniform(IndentDelta::Spaces(-10));
|
||||
let out = r.push(" abc\n");
|
||||
// max(0, 2 - 10) = 0, so no leading spaces.
|
||||
assert_eq!(out, "abc\n");
|
||||
|
|
@ -170,7 +216,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_whitespace_only_lines() {
|
||||
let mut r = Reindenter::new(IndentDelta::Spaces(2));
|
||||
let mut r = Reindenter::uniform(IndentDelta::Spaces(2));
|
||||
let out = r.push(" \n code\n");
|
||||
// First line is all whitespace — emitted verbatim. Second line is indented.
|
||||
assert_eq!(out, " \n code\n");
|
||||
|
|
@ -178,6 +224,95 @@ mod tests {
|
|||
assert_eq!(out, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_distinct_first_line_delta() {
|
||||
// First line's indentation was stripped in the query (delta +8),
|
||||
// while the remaining lines are already correct (delta 0). Chunks
|
||||
// split mid-line and mid-indentation to exercise the streaming path,
|
||||
// and the blank line is passed through verbatim.
|
||||
let mut r = Reindenter::with_deltas(IndentDelta::Spaces(8), IndentDelta::Spaces(0));
|
||||
let mut out = r.push("self.target_a = ");
|
||||
out.push_str(&r.push("\"after\"\n "));
|
||||
out.push_str(&r.push(" self.target_b = \"after\"\n"));
|
||||
out.push_str(&r.push("\n self.target_c = \"after\""));
|
||||
out.push_str(&r.finish());
|
||||
assert_eq!(
|
||||
out,
|
||||
concat!(
|
||||
" self.target_a = \"after\"\n",
|
||||
" self.target_b = \"after\"\n",
|
||||
"\n",
|
||||
" self.target_c = \"after\"",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn line_indent(text: &str) -> LineIndent {
|
||||
LineIndent::from_iter(text.chars())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_rest_indent_delta() {
|
||||
let first_line_delta = IndentDelta::Spaces(8);
|
||||
|
||||
// Remaining lines that agree on a delta override the first-line
|
||||
// delta, and blank lines are skipped when forming the consensus.
|
||||
assert_eq!(
|
||||
compute_rest_indent_delta(
|
||||
first_line_delta,
|
||||
vec![
|
||||
(line_indent(" b"), line_indent(" b")),
|
||||
(line_indent(""), line_indent("")),
|
||||
(line_indent(" c"), line_indent(" c")),
|
||||
],
|
||||
),
|
||||
IndentDelta::Spaces(0)
|
||||
);
|
||||
assert_eq!(
|
||||
compute_rest_indent_delta(
|
||||
first_line_delta,
|
||||
vec![
|
||||
(line_indent(" b"), line_indent(" b")),
|
||||
(line_indent(" "), line_indent("")),
|
||||
(line_indent(" c"), line_indent(" c")),
|
||||
],
|
||||
),
|
||||
IndentDelta::Spaces(4)
|
||||
);
|
||||
assert_eq!(
|
||||
compute_rest_indent_delta(
|
||||
first_line_delta,
|
||||
vec![(line_indent("\t\tb"), line_indent("\tb"))],
|
||||
),
|
||||
IndentDelta::Tabs(1)
|
||||
);
|
||||
|
||||
// Inconsistent remaining lines fall back to the first-line delta...
|
||||
assert_eq!(
|
||||
compute_rest_indent_delta(
|
||||
first_line_delta,
|
||||
vec![
|
||||
(line_indent(" b"), line_indent(" b")),
|
||||
(line_indent(" c"), line_indent(" c")),
|
||||
],
|
||||
),
|
||||
first_line_delta
|
||||
);
|
||||
|
||||
// ...and so do all-blank and empty pairings.
|
||||
assert_eq!(
|
||||
compute_rest_indent_delta(
|
||||
first_line_delta,
|
||||
vec![(line_indent(" "), line_indent(""))],
|
||||
),
|
||||
first_line_delta
|
||||
);
|
||||
assert_eq!(
|
||||
compute_rest_indent_delta(first_line_delta, vec![]),
|
||||
first_line_delta
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_indent_delta_spaces() {
|
||||
let buffer = LineIndent {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,18 @@ pub struct StreamingFuzzyMatcher {
|
|||
query_lines: Vec<String>,
|
||||
line_hint: Option<u32>,
|
||||
incomplete_line: String,
|
||||
matches: Vec<Range<usize>>,
|
||||
matches: Vec<SearchMatch>,
|
||||
matrix: SearchMatrix,
|
||||
}
|
||||
|
||||
/// A match candidate: the matched byte range plus the 0-based
|
||||
/// `(query_row, buffer_row)` line pairs the search aligned to produce it.
|
||||
#[derive(Clone, Debug)]
|
||||
struct SearchMatch {
|
||||
range: Range<usize>,
|
||||
line_pairs: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl StreamingFuzzyMatcher {
|
||||
pub fn new(snapshot: TextBufferSnapshot) -> Self {
|
||||
let buffer_line_count = snapshot.max_point().row as usize + 1;
|
||||
|
|
@ -34,6 +42,23 @@ impl StreamingFuzzyMatcher {
|
|||
&self.query_lines
|
||||
}
|
||||
|
||||
/// Returns the 0-based `(query_row, buffer_row)` line pairs that the
|
||||
/// search aligned for the match with the given range. Lines that were
|
||||
/// skipped on either side of the alignment are absent.
|
||||
pub fn line_pairs(&self, range: &Range<usize>) -> Option<&[(u32, u32)]> {
|
||||
self.matches
|
||||
.iter()
|
||||
.find(|search_match| search_match.range == *range)
|
||||
.map(|search_match| search_match.line_pairs.as_slice())
|
||||
}
|
||||
|
||||
fn match_ranges(&self) -> Vec<Range<usize>> {
|
||||
self.matches
|
||||
.iter()
|
||||
.map(|search_match| search_match.range.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push a new chunk of text and get the best match found so far.
|
||||
///
|
||||
/// This method accumulates text chunks and processes complete lines.
|
||||
|
|
@ -62,7 +87,11 @@ impl StreamingFuzzyMatcher {
|
|||
}
|
||||
|
||||
let best_match = self.select_best_match();
|
||||
best_match.or_else(|| self.matches.first().cloned())
|
||||
best_match.or_else(|| {
|
||||
self.matches
|
||||
.first()
|
||||
.map(|search_match| search_match.range.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Finish processing and return the final best match(es).
|
||||
|
|
@ -72,26 +101,35 @@ impl StreamingFuzzyMatcher {
|
|||
pub fn finish(&mut self) -> Vec<Range<usize>> {
|
||||
// Process any remaining incomplete line
|
||||
if !self.incomplete_line.is_empty() {
|
||||
if self.matches.len() == 1 {
|
||||
let range = &mut self.matches[0];
|
||||
if let [only_match] = self.matches.as_mut_slice() {
|
||||
let range = &mut only_match.range;
|
||||
if range.end < self.snapshot.len()
|
||||
&& self
|
||||
.snapshot
|
||||
.contains_str_at(range.end + 1, &self.incomplete_line)
|
||||
{
|
||||
range.end += 1 + self.incomplete_line.len();
|
||||
return self.matches.clone();
|
||||
// Record the line and its alignment so that `query_lines`
|
||||
// and `line_pairs` stay in sync with the lines covered by
|
||||
// the returned range.
|
||||
let extended_row = self.snapshot.offset_to_point(range.end).row;
|
||||
self.query_lines
|
||||
.push(std::mem::take(&mut self.incomplete_line));
|
||||
only_match
|
||||
.line_pairs
|
||||
.push(((self.query_lines.len() - 1) as u32, extended_row));
|
||||
return self.match_ranges();
|
||||
}
|
||||
}
|
||||
|
||||
self.query_lines.push(self.incomplete_line.clone());
|
||||
self.incomplete_line.clear();
|
||||
self.query_lines
|
||||
.push(std::mem::take(&mut self.incomplete_line));
|
||||
self.matches = self.resolve_location_fuzzy();
|
||||
}
|
||||
self.matches.clone()
|
||||
self.match_ranges()
|
||||
}
|
||||
|
||||
fn resolve_location_fuzzy(&mut self) -> Vec<Range<usize>> {
|
||||
fn resolve_location_fuzzy(&mut self) -> Vec<SearchMatch> {
|
||||
let new_query_line_count = self.query_lines.len();
|
||||
let old_query_line_count = self.matrix.rows.saturating_sub(1);
|
||||
if new_query_line_count == old_query_line_count {
|
||||
|
|
@ -167,7 +205,7 @@ impl StreamingFuzzyMatcher {
|
|||
// Find ranges for the matches
|
||||
let mut valid_matches = Vec::new();
|
||||
for &buffer_row_end in &matches_with_best_cost {
|
||||
let mut matched_lines = 0;
|
||||
let mut line_pairs = Vec::new();
|
||||
let mut query_row = new_query_line_count;
|
||||
let mut buffer_row_start = buffer_row_end;
|
||||
while query_row > 0 && buffer_row_start > 0 {
|
||||
|
|
@ -176,7 +214,7 @@ impl StreamingFuzzyMatcher {
|
|||
SearchDirection::Diagonal => {
|
||||
query_row -= 1;
|
||||
buffer_row_start -= 1;
|
||||
matched_lines += 1;
|
||||
line_pairs.push((query_row as u32, buffer_row_start));
|
||||
}
|
||||
SearchDirection::Up => {
|
||||
query_row -= 1;
|
||||
|
|
@ -186,9 +224,10 @@ impl StreamingFuzzyMatcher {
|
|||
}
|
||||
}
|
||||
}
|
||||
line_pairs.reverse();
|
||||
|
||||
let matched_buffer_row_count = buffer_row_end - buffer_row_start;
|
||||
let matched_ratio = matched_lines as f32
|
||||
let matched_ratio = line_pairs.len() as f32
|
||||
/ (matched_buffer_row_count as f32).max(new_query_line_count as f32);
|
||||
if matched_ratio >= 0.8 {
|
||||
let buffer_start_ix = self
|
||||
|
|
@ -198,11 +237,14 @@ impl StreamingFuzzyMatcher {
|
|||
buffer_row_end - 1,
|
||||
self.snapshot.line_len(buffer_row_end - 1),
|
||||
));
|
||||
valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix));
|
||||
valid_matches.push(SearchMatch {
|
||||
range: buffer_start_ix..buffer_end_ix,
|
||||
line_pairs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
valid_matches.into_iter().map(|(_, range)| range).collect()
|
||||
valid_matches
|
||||
}
|
||||
|
||||
/// Return the best match with starting position close enough to line_hint.
|
||||
|
|
@ -216,8 +258,8 @@ impl StreamingFuzzyMatcher {
|
|||
return None;
|
||||
}
|
||||
|
||||
if self.matches.len() == 1 {
|
||||
return self.matches.first().cloned();
|
||||
if let [only_match] = self.matches.as_slice() {
|
||||
return Some(only_match.range.clone());
|
||||
}
|
||||
|
||||
let Some(line_hint) = self.line_hint else {
|
||||
|
|
@ -228,14 +270,14 @@ impl StreamingFuzzyMatcher {
|
|||
let mut best_match = None;
|
||||
let mut best_distance = u32::MAX;
|
||||
|
||||
for range in &self.matches {
|
||||
let start_point = self.snapshot.offset_to_point(range.start);
|
||||
for search_match in &self.matches {
|
||||
let start_point = self.snapshot.offset_to_point(search_match.range.start);
|
||||
let start_line = start_point.row;
|
||||
let distance = start_line.abs_diff(line_hint);
|
||||
|
||||
if distance <= LINE_HINT_TOLERANCE && distance < best_distance {
|
||||
best_distance = distance;
|
||||
best_match = Some(range.clone());
|
||||
best_match = Some(search_match.range.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -831,6 +873,79 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_pairs_skip_unmatched_buffer_line() {
|
||||
let text = indoc! {r#"
|
||||
class Outer:
|
||||
def method(self):
|
||||
self.kept = "unchanged"
|
||||
self.target_a = "before"
|
||||
self.extra = "row"
|
||||
self.target_b = "before"
|
||||
self.target_c = "before"
|
||||
self.target_d = "before"
|
||||
self.kept_2 = "unchanged"
|
||||
"#};
|
||||
let buffer = TextBuffer::new(
|
||||
ReplicaId::LOCAL,
|
||||
BufferId::new(1).unwrap(),
|
||||
text.to_string(),
|
||||
);
|
||||
let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
|
||||
|
||||
// The query omits the `self.extra` row that sits between the matched
|
||||
// buffer lines.
|
||||
matcher.push(
|
||||
concat!(
|
||||
" self.target_a = \"before\"\n",
|
||||
" self.target_b = \"before\"\n",
|
||||
" self.target_c = \"before\"\n",
|
||||
" self.target_d = \"before\"\n",
|
||||
),
|
||||
None,
|
||||
);
|
||||
let matches = matcher.finish();
|
||||
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(
|
||||
matcher.line_pairs(&matches[0]),
|
||||
Some(&[(0, 3), (1, 5), (2, 6), (3, 7)][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_pairs_include_extended_incomplete_line() {
|
||||
let text = indoc! {r#"
|
||||
fn on_query_change(&mut self, cx: &mut Context<Self>) {
|
||||
self.filter(cx);
|
||||
}
|
||||
|
||||
|
||||
|
||||
fn render_search(&self, cx: &mut Context<Self>) -> Div {
|
||||
div()
|
||||
}
|
||||
"#};
|
||||
let buffer = TextBuffer::new(
|
||||
ReplicaId::LOCAL,
|
||||
BufferId::new(1).unwrap(),
|
||||
text.to_string(),
|
||||
);
|
||||
let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
|
||||
|
||||
// The last query line is incomplete and gets appended to the match by
|
||||
// `finish` via verbatim comparison rather than the fuzzy search.
|
||||
matcher.push("}\n\n\n\nfn render_search", None);
|
||||
let matches = matcher.finish();
|
||||
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(
|
||||
matcher.line_pairs(&matches[0]),
|
||||
Some(&[(0, 2), (1, 3), (2, 4), (3, 5), (4, 6)][..])
|
||||
);
|
||||
assert_eq!(matcher.query_lines().len(), 5);
|
||||
}
|
||||
|
||||
fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec<String> {
|
||||
let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50));
|
||||
let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count);
|
||||
|
|
|
|||
|
|
@ -503,7 +503,9 @@ impl EditToolTest {
|
|||
if tool_use.is_input_complete
|
||||
&& tool_use.name.as_ref() == EditFileTool::NAME =>
|
||||
{
|
||||
let input: EditFileToolInput = serde_json::from_value(tool_use.input)
|
||||
let input: EditFileToolInput = tool_use
|
||||
.input
|
||||
.parse()
|
||||
.context("Failed to parse tool input as EditFileToolInput")?;
|
||||
return Ok(input);
|
||||
}
|
||||
|
|
@ -607,7 +609,9 @@ fn tool_use(
|
|||
id: LanguageModelToolUseId::from(id.into()),
|
||||
name: name.into(),
|
||||
raw_input: serde_json::to_string_pretty(&input).unwrap(),
|
||||
input: serde_json::to_value(input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -327,7 +327,9 @@ async fn extract_tool_use(
|
|||
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
|
||||
if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME =>
|
||||
{
|
||||
let input: TerminalToolInput = serde_json::from_value(tool_use.input)
|
||||
let input: TerminalToolInput = tool_use
|
||||
.input
|
||||
.parse()
|
||||
.context("Failed to parse tool input as TerminalToolInput")?;
|
||||
return Ok(input);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,7 +323,9 @@ impl WriteToolTest {
|
|||
if tool_use.is_input_complete
|
||||
&& tool_use.name.as_ref() == WriteFileTool::NAME =>
|
||||
{
|
||||
let input: WriteFileToolInput = serde_json::from_value(tool_use.input)
|
||||
let input: WriteFileToolInput = tool_use
|
||||
.input
|
||||
.parse()
|
||||
.context("Failed to parse tool input as WriteFileToolInput")?;
|
||||
return Ok(input);
|
||||
}
|
||||
|
|
@ -410,7 +412,9 @@ fn tool_use(
|
|||
id: LanguageModelToolUseId::from(id.into()),
|
||||
name: name.into(),
|
||||
raw_input: serde_json::to_string_pretty(&input).unwrap(),
|
||||
input: serde_json::to_value(input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(input).unwrap(),
|
||||
),
|
||||
is_input_complete: true,
|
||||
thought_signature: None,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,12 +23,38 @@ enum ContentType {
|
|||
Json,
|
||||
}
|
||||
|
||||
/// The maximum number of HTTP redirects the fetch tool will follow. Each hop is
|
||||
/// re-authorized against the shared network grants before being followed.
|
||||
const MAX_REDIRECTS: usize = 20;
|
||||
|
||||
/// The outcome of a single (non-redirect-following) HTTP request.
|
||||
enum FetchStep {
|
||||
/// The server responded with a redirect to this absolute URL. Its host must
|
||||
/// be authorized before the redirect is followed.
|
||||
Redirect(String),
|
||||
/// A terminal response was received and converted to Markdown.
|
||||
Complete(String),
|
||||
}
|
||||
|
||||
/// Prepends `https://` when the URL has no explicit HTTP(S) scheme, matching the
|
||||
/// behavior the fetch tool has always had for user/model-supplied URLs.
|
||||
fn normalize_url(url: &str) -> Cow<'_, str> {
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
Cow::Owned(format!("https://{url}"))
|
||||
} else {
|
||||
Cow::Borrowed(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches a URL and returns the content as Markdown.
|
||||
///
|
||||
/// This tool is not run inside the terminal OS sandbox, but it still refuses to
|
||||
/// reach any host that hasn't been granted network access. It shares the same
|
||||
/// per-host grants as the `terminal` tool: approving a host for one authorizes
|
||||
/// it for the other, whether the grant is for this thread or saved permanently.
|
||||
/// HTTP redirects are followed one hop at a time, and each hop's host must be
|
||||
/// granted the same way, so a granted host can't redirect the request to a host
|
||||
/// that hasn't been approved.
|
||||
/// When unsandboxed access has been granted, these restrictions are lifted
|
||||
/// entirely, matching the terminal, which is also how loopback and IP-literal
|
||||
/// hosts (which can't be granted individually) become reachable.
|
||||
|
|
@ -47,14 +73,35 @@ impl FetchTool {
|
|||
Self { http_client }
|
||||
}
|
||||
|
||||
async fn build_message(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<String> {
|
||||
let url = if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
Cow::Owned(format!("https://{url}"))
|
||||
} else {
|
||||
Cow::Borrowed(url)
|
||||
};
|
||||
/// Performs a single HTTP GET *without* following redirects, so the tool can
|
||||
/// re-authorize each hop against the shared network grants before following
|
||||
/// it. Returns the redirect target when the server responds with a 3xx, or
|
||||
/// the final content converted to Markdown otherwise.
|
||||
async fn fetch_step(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<FetchStep> {
|
||||
let normalized = normalize_url(url);
|
||||
|
||||
let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
|
||||
let mut response = http_client
|
||||
.get(&normalized, AsyncBody::default(), false)
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_redirection() {
|
||||
let location = response
|
||||
.headers()
|
||||
.get("location")
|
||||
.context("redirect response is missing a Location header")?
|
||||
.to_str()
|
||||
.context("redirect response has an invalid Location header")?;
|
||||
let target = url::Url::parse(&normalized)
|
||||
.with_context(|| format!("could not parse URL {normalized:?}"))?
|
||||
.join(location)
|
||||
.with_context(|| format!("invalid redirect target {location:?}"))?;
|
||||
anyhow::ensure!(
|
||||
matches!(target.scheme(), "http" | "https"),
|
||||
"refusing to follow redirect to non-HTTP(S) URL {target}"
|
||||
);
|
||||
return Ok(FetchStep::Redirect(target.to_string()));
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
response
|
||||
|
|
@ -63,12 +110,9 @@ impl FetchTool {
|
|||
.await
|
||||
.context("error reading response body")?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
if status.is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
bail!("status error {}, response: {text:?}", status.as_u16());
|
||||
}
|
||||
|
||||
let Some(content_type) = response.headers().get("content-type") else {
|
||||
|
|
@ -86,7 +130,7 @@ impl FetchTool {
|
|||
ContentType::Html
|
||||
};
|
||||
|
||||
match content_type {
|
||||
let text = match content_type {
|
||||
ContentType::Html => {
|
||||
let mut handlers: Vec<TagHandler> = vec![
|
||||
Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
|
||||
|
|
@ -96,7 +140,7 @@ impl FetchTool {
|
|||
Rc::new(RefCell::new(markdown::TableHandler::new())),
|
||||
Rc::new(RefCell::new(markdown::StyledTextHandler)),
|
||||
];
|
||||
if url.contains("wikipedia.org") {
|
||||
if normalized.contains("wikipedia.org") {
|
||||
use html_to_markdown::structure::wikipedia;
|
||||
|
||||
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
|
||||
|
|
@ -108,30 +152,25 @@ impl FetchTool {
|
|||
handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
|
||||
}
|
||||
|
||||
convert_html_to_markdown(&body[..], &mut handlers)
|
||||
convert_html_to_markdown(&body[..], &mut handlers)?
|
||||
}
|
||||
ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
|
||||
ContentType::Plaintext => std::str::from_utf8(&body)?.to_owned(),
|
||||
ContentType::Json => {
|
||||
let json: serde_json::Value = serde_json::from_slice(&body)?;
|
||||
|
||||
Ok(format!(
|
||||
"```json\n{}\n```",
|
||||
serde_json::to_string_pretty(&json)?
|
||||
))
|
||||
format!("```json\n{}\n```", serde_json::to_string_pretty(&json)?)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FetchStep::Complete(text))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can
|
||||
/// be matched against the shared network grants. Mirrors the scheme handling in
|
||||
/// [`FetchTool::build_message`] (defaulting to `https://` when none is given).
|
||||
/// [`normalize_url`] (defaulting to `https://` when none is given).
|
||||
fn host_pattern_for_url(url: &str) -> Result<http_proxy::HostPattern> {
|
||||
let normalized = if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
Cow::Owned(format!("https://{url}"))
|
||||
} else {
|
||||
Cow::Borrowed(url)
|
||||
};
|
||||
let normalized = normalize_url(url);
|
||||
let parsed =
|
||||
url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?;
|
||||
let host = parsed
|
||||
|
|
@ -211,36 +250,61 @@ impl AgentTool for FetchTool {
|
|||
// already runs without isolation, so we drop fetch's restrictions
|
||||
// too — including reaching hosts that can't be granted individually
|
||||
// (loopback and IP literals).
|
||||
//
|
||||
// Crucially, this authorization is applied to every redirect hop as
|
||||
// well as the initial URL, so a granted host can't 30x-redirect the
|
||||
// fetch to a host the user never approved. We disable the HTTP
|
||||
// client's own redirect following and re-run the grant for each hop
|
||||
// before requesting it.
|
||||
let unsandboxed = cx.update(|cx| event_stream.unsandboxed_access_granted(cx));
|
||||
if !unsandboxed {
|
||||
let host = host_pattern_for_url(&input.url).map_err(|e| e.to_string())?;
|
||||
let authorize_host = cx.update(|cx| {
|
||||
let request = SandboxRequest {
|
||||
network: NetworkRequest::Hosts(vec![host]),
|
||||
..Default::default()
|
||||
|
||||
let mut current_url = input.url.clone();
|
||||
let mut redirects = 0;
|
||||
let text = loop {
|
||||
if !unsandboxed {
|
||||
let host = host_pattern_for_url(¤t_url).map_err(|e| e.to_string())?;
|
||||
let authorize_host = cx.update(|cx| {
|
||||
let request = SandboxRequest {
|
||||
network: NetworkRequest::Hosts(vec![host]),
|
||||
..Default::default()
|
||||
};
|
||||
event_stream.authorize_sandbox(request, String::new(), cx)
|
||||
});
|
||||
futures::select! {
|
||||
result = authorize_host.fuse() => result.map_err(|e| e.to_string())?,
|
||||
_ = event_stream.cancelled_by_user().fuse() => {
|
||||
return Err("Fetch cancelled by user".to_string());
|
||||
}
|
||||
};
|
||||
event_stream.authorize_sandbox(request, String::new(), cx)
|
||||
}
|
||||
|
||||
let fetch_task = cx.background_spawn({
|
||||
let http_client = http_client.clone();
|
||||
let url = current_url.clone();
|
||||
async move { Self::fetch_step(http_client, &url).await }
|
||||
});
|
||||
futures::select! {
|
||||
result = authorize_host.fuse() => result.map_err(|e| e.to_string())?,
|
||||
|
||||
let step = futures::select! {
|
||||
result = fetch_task.fuse() => result.map_err(|e| e.to_string())?,
|
||||
_ = event_stream.cancelled_by_user().fuse() => {
|
||||
return Err("Fetch cancelled by user".to_string());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let fetch_task = cx.background_spawn({
|
||||
let http_client = http_client.clone();
|
||||
let url = input.url.clone();
|
||||
async move { Self::build_message(http_client, &url).await }
|
||||
});
|
||||
|
||||
let text = futures::select! {
|
||||
result = fetch_task.fuse() => result.map_err(|e| e.to_string())?,
|
||||
_ = event_stream.cancelled_by_user().fuse() => {
|
||||
return Err("Fetch cancelled by user".to_string());
|
||||
match step {
|
||||
FetchStep::Complete(text) => break text,
|
||||
FetchStep::Redirect(target) => {
|
||||
redirects += 1;
|
||||
if redirects > MAX_REDIRECTS {
|
||||
return Err(format!(
|
||||
"exceeded the maximum of {MAX_REDIRECTS} redirects"
|
||||
));
|
||||
}
|
||||
current_url = target;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
return Err("no textual content found".to_string());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -558,8 +558,10 @@ async fn run_terminal_tool(
|
|||
if !path.is_dir() {
|
||||
return Err(format!(
|
||||
"Cannot request sandbox write access to `{}`: on Linux, write access can only \
|
||||
be granted to directories that already exist. To create or modify files, \
|
||||
request write access to the existing directory that contains them, not the \
|
||||
be granted to directories that already exist. To create a new directory to write \
|
||||
into, use the `create_directory` tool (which creates it and grants write access to \
|
||||
exactly that directory) rather than requesting its parent. To modify existing \
|
||||
files, request write access to the existing directory that contains them, not the \
|
||||
file path itself.",
|
||||
path.display()
|
||||
));
|
||||
|
|
@ -683,6 +685,7 @@ async fn run_terminal_tool(
|
|||
event_stream.authorize_sandbox_fallback(
|
||||
Some(input.command.clone()),
|
||||
error.user_facing_message(),
|
||||
Some(error.docs_section().to_string()),
|
||||
retries,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -788,6 +791,7 @@ async fn run_terminal_tool(
|
|||
event_stream.authorize_sandbox_fallback(
|
||||
Some(input.command.clone()),
|
||||
sandbox_error.user_facing_message(),
|
||||
Some(sandbox_error.docs_section().to_string()),
|
||||
retries,
|
||||
cx,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -753,10 +753,7 @@ fn connect_client_future(
|
|||
)
|
||||
}
|
||||
|
||||
fn client_capabilities_for_agent(
|
||||
agent_id: &AgentId,
|
||||
supports_beta_features: bool,
|
||||
) -> acp::ClientCapabilities {
|
||||
fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities {
|
||||
let mut meta = acp::Meta::from_iter([
|
||||
("terminal_output".into(), true.into()),
|
||||
("terminal-auth".into(), true.into()),
|
||||
|
|
@ -766,7 +763,7 @@ fn client_capabilities_for_agent(
|
|||
meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into());
|
||||
}
|
||||
|
||||
let mut capabilities = acp::ClientCapabilities::new()
|
||||
acp::ClientCapabilities::new()
|
||||
.fs(acp::FileSystemCapabilities::new()
|
||||
.read_text_file(true)
|
||||
.write_text_file(true))
|
||||
|
|
@ -778,17 +775,12 @@ fn client_capabilities_for_agent(
|
|||
.boolean(acp::BooleanConfigOptionCapabilities::new()),
|
||||
),
|
||||
)
|
||||
.meta(meta);
|
||||
|
||||
if supports_beta_features {
|
||||
capabilities = capabilities.elicitation(
|
||||
.elicitation(
|
||||
acp::ElicitationCapabilities::new()
|
||||
.form(acp::ElicitationFormCapabilities::new())
|
||||
.url(acp::ElicitationUrlCapabilities::new()),
|
||||
);
|
||||
}
|
||||
|
||||
capabilities
|
||||
)
|
||||
.meta(meta)
|
||||
}
|
||||
|
||||
impl AcpConnection {
|
||||
|
|
@ -980,10 +972,7 @@ impl AcpConnection {
|
|||
let initialize_response = connection
|
||||
.send_request(
|
||||
acp::InitializeRequest::new(ProtocolVersion::V1)
|
||||
.client_capabilities(client_capabilities_for_agent(
|
||||
&agent_id,
|
||||
cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()),
|
||||
))
|
||||
.client_capabilities(client_capabilities_for_agent(&agent_id))
|
||||
.client_info(
|
||||
acp::Implementation::new("zed", version)
|
||||
.title(release_channel.map(ToOwned::to_owned)),
|
||||
|
|
@ -2698,7 +2687,6 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use feature_flags::FeatureFlag as _;
|
||||
use gpui::UpdateGlobal as _;
|
||||
use settings::Settings as _;
|
||||
|
||||
fn init_feature_flags_test(cx: &mut gpui::TestAppContext) {
|
||||
|
|
@ -2710,50 +2698,15 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
fn set_acp_beta_override(value: &str, cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |content| {
|
||||
content
|
||||
.feature_flags
|
||||
.get_or_insert_default()
|
||||
.insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn client_capabilities_omit_elicitation_without_acp_beta(cx: &mut gpui::TestAppContext) {
|
||||
async fn client_capabilities_include_elicitation_without_acp_beta(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
init_feature_flags_test(cx);
|
||||
set_acp_beta_override("off", cx);
|
||||
|
||||
let capabilities = cx.update(|cx| {
|
||||
client_capabilities_for_agent(
|
||||
&AgentId::new("codex-acp"),
|
||||
cx.has_flag::<AcpBetaFeatureFlag>(),
|
||||
)
|
||||
});
|
||||
|
||||
assert!(capabilities.elicitation.is_none());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn client_capabilities_include_elicitation_with_acp_beta(cx: &mut gpui::TestAppContext) {
|
||||
init_feature_flags_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
|
||||
});
|
||||
|
||||
let capabilities = cx.update(|cx| {
|
||||
client_capabilities_for_agent(
|
||||
&AgentId::new("codex-acp"),
|
||||
cx.has_flag::<AcpBetaFeatureFlag>(),
|
||||
)
|
||||
});
|
||||
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
|
||||
let elicitation = capabilities
|
||||
.elicitation
|
||||
.expect("elicitation should be advertised when acp-beta is enabled");
|
||||
.expect("elicitation should always be advertised");
|
||||
|
||||
assert!(elicitation.form.is_some());
|
||||
assert!(elicitation.url.is_some());
|
||||
|
|
@ -3016,7 +2969,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn cursor_client_capabilities_include_parameterized_model_picker_meta() {
|
||||
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false);
|
||||
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID));
|
||||
let meta = capabilities
|
||||
.meta
|
||||
.expect("expected client capabilities meta");
|
||||
|
|
@ -3031,7 +2984,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() {
|
||||
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
|
||||
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
|
||||
let meta = capabilities
|
||||
.meta
|
||||
.expect("expected client capabilities meta");
|
||||
|
|
@ -3041,7 +2994,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn client_capabilities_include_boolean_config_options() {
|
||||
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
|
||||
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
|
||||
|
||||
assert!(
|
||||
capabilities
|
||||
|
|
@ -4566,13 +4519,6 @@ fn handle_create_elicitation(
|
|||
cx: &mut AsyncApp,
|
||||
ctx: &ClientContext,
|
||||
) {
|
||||
if !cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()) {
|
||||
return respond_err(
|
||||
responder,
|
||||
acp::Error::invalid_params().data("elicitation support requires the ACP beta flag"),
|
||||
);
|
||||
}
|
||||
|
||||
match args.scope() {
|
||||
acp::ElicitationScope::Session(scope) => {
|
||||
let thread = match session_thread(ctx, &scope.session_id) {
|
||||
|
|
@ -4661,10 +4607,6 @@ fn handle_complete_elicitation(
|
|||
cx: &mut AsyncApp,
|
||||
ctx: &ClientContext,
|
||||
) {
|
||||
if !cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let threads = ctx
|
||||
.sessions
|
||||
.borrow()
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ impl Default for AgentProfileId {
|
|||
/// combines them with the in-memory per-thread grants. `write_paths` are
|
||||
/// stored as minimal, lexically-normalized subtrees (see
|
||||
/// [`compile_sandbox_permissions`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SandboxPermissions {
|
||||
/// Allow sandboxed commands to reach any host over the network.
|
||||
pub allow_all_hosts: bool,
|
||||
|
|
@ -432,6 +432,24 @@ pub struct SandboxPermissions {
|
|||
/// tool/prompt in place — see `agent::sandboxing`.
|
||||
pub allow_unsandboxed: bool,
|
||||
pub write_paths: Vec<PathBuf>,
|
||||
/// Whether sandbox escalation prompts warn about domains or write paths
|
||||
/// that contain potentially confusable Unicode characters (homoglyphs,
|
||||
/// invisible characters, or bidirectional overrides). Enabled by default.
|
||||
pub warn_confusable_unicode: bool,
|
||||
}
|
||||
|
||||
impl Default for SandboxPermissions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_all_hosts: false,
|
||||
network_hosts: Vec::new(),
|
||||
allow_fs_write_all: false,
|
||||
allow_unsandboxed: false,
|
||||
write_paths: Vec::new(),
|
||||
// The confusable-Unicode warning is a safety net, so it defaults on.
|
||||
warn_confusable_unicode: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -821,6 +839,7 @@ fn compile_sandbox_permissions(
|
|||
allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false),
|
||||
allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
|
||||
write_paths,
|
||||
warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1098,6 +1117,22 @@ mod tests {
|
|||
fn test_sandbox_permissions_empty() {
|
||||
let permissions = compile_sandbox_permissions(None);
|
||||
assert_eq!(permissions, SandboxPermissions::default());
|
||||
// The confusable-Unicode warning is a safety net, so it's on by default.
|
||||
assert!(permissions.warn_confusable_unicode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_permissions_warn_confusable_unicode_can_be_disabled() {
|
||||
let content: settings::SandboxPermissionsContent =
|
||||
serde_json::from_value(json!({ "warn_confusable_unicode": false })).unwrap();
|
||||
let permissions = compile_sandbox_permissions(Some(content));
|
||||
assert!(!permissions.warn_confusable_unicode);
|
||||
|
||||
// Omitting the key keeps the warning enabled.
|
||||
let content: settings::SandboxPermissionsContent =
|
||||
serde_json::from_value(json!({})).unwrap();
|
||||
let permissions = compile_sandbox_permissions(Some(content));
|
||||
assert!(permissions.warn_confusable_unicode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ gpui.workspace = true
|
|||
gpui_tokio.workspace = true
|
||||
html_to_markdown.workspace = true
|
||||
http_client.workspace = true
|
||||
idna.workspace = true
|
||||
indoc.workspace = true
|
||||
itertools.workspace = true
|
||||
jsonschema.workspace = true
|
||||
|
|
@ -108,6 +109,7 @@ theme_settings.workspace = true
|
|||
time.workspace = true
|
||||
ui.workspace = true
|
||||
ui_input.workspace = true
|
||||
unicode-script.workspace = true
|
||||
unicode-segmentation.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ use std::{
|
|||
sync::Arc,
|
||||
};
|
||||
use ui::{CommonAnimationExt, Divider, IconButtonShape, KeyBinding, Tooltip, prelude::*};
|
||||
use util::ResultExt;
|
||||
use util::{ResultExt, truncate_and_trailoff};
|
||||
use workspace::{
|
||||
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
|
||||
Workspace,
|
||||
item::{ItemEvent, SaveOptions, TabContentParams},
|
||||
item::{ItemEvent, SaveOptions, TabContentParams, TabTooltipContent},
|
||||
searchable::SearchableItemHandle,
|
||||
};
|
||||
use zed_actions::assistant::ToggleFocus;
|
||||
|
|
@ -528,23 +528,33 @@ impl Item for AgentDiffPane {
|
|||
.update(cx, |editor, cx| editor.navigate(data, window, cx))
|
||||
}
|
||||
|
||||
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
|
||||
Some("Agent Diff".into())
|
||||
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
|
||||
let label_content = self.tab_content_text(params.detail.unwrap_or_default(), cx);
|
||||
|
||||
Label::new(label_content)
|
||||
.when(!params.selected, |this| this.color(Color::Muted))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
|
||||
fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
|
||||
let title = self.thread.read(cx).title();
|
||||
Label::new(if let Some(title) = title {
|
||||
format!("Review: {}", title)
|
||||
} else {
|
||||
"Review".to_string()
|
||||
})
|
||||
.color(if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.into_any_element()
|
||||
|
||||
Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
|
||||
let title = title.map(|title| title.to_string());
|
||||
|
||||
move |_, _| {
|
||||
v_flex()
|
||||
.child(Label::new(
|
||||
title.clone().unwrap_or_else(|| "Review".to_string()),
|
||||
))
|
||||
.child(
|
||||
Label::new("Agent Diff")
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}))))
|
||||
}
|
||||
|
||||
fn telemetry_event_text(&self) -> Option<&'static str> {
|
||||
|
|
@ -666,8 +676,11 @@ impl Item for AgentDiffPane {
|
|||
});
|
||||
}
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
|
||||
"Agent Diff".into()
|
||||
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
|
||||
match self.thread.read(cx).title() {
|
||||
Some(title) => format!("Review: {}", truncate_and_trailoff(&title, 20)).into(),
|
||||
None => "Review".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2037,6 +2037,7 @@ impl AgentPanel {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.pending_terminal_spawn = Some(terminal_id);
|
||||
let terminal_working_directory = working_directory.clone();
|
||||
let init_command = Self::terminal_init_command(run_init_command, cx);
|
||||
let terminal_task = self.project.update(cx, |project, cx| {
|
||||
|
|
@ -5715,24 +5716,6 @@ impl AgentPanel {
|
|||
}
|
||||
|
||||
menu = menu
|
||||
.separator()
|
||||
.header("MCP Servers")
|
||||
.action(
|
||||
"Add Server…",
|
||||
Box::new(zed_actions::OpenSettingsAt {
|
||||
path: "context_servers".to_string(),
|
||||
target: None,
|
||||
}),
|
||||
)
|
||||
.action(
|
||||
"Install New Servers…",
|
||||
Box::new(zed_actions::Extensions {
|
||||
category_filter: Some(
|
||||
zed_actions::ExtensionCategoryFilter::ContextServers,
|
||||
),
|
||||
id: None,
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.action("Profiles", Box::new(ManageProfiles::default()));
|
||||
}
|
||||
|
|
@ -6502,7 +6485,6 @@ impl Render for AgentPanel {
|
|||
.and_then(|terminal_id| self.terminals.get(&terminal_id))
|
||||
.and_then(|terminal| terminal.search_bar.clone());
|
||||
let terminal_content = v_flex()
|
||||
.key_context("AgentTerminalThread")
|
||||
.size_full()
|
||||
.when_some(search_bar, |this, search_bar| {
|
||||
this.when(!search_bar.read(cx).is_dismissed(), |this| {
|
||||
|
|
@ -7382,6 +7364,34 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_explicit_terminal_blocks_redundant_auto_init(cx: &mut TestAppContext) {
|
||||
let (panel, mut cx) = setup_panel(cx).await;
|
||||
|
||||
panel.update_in(&mut cx, |panel, window, cx| {
|
||||
panel.last_created_entry_kind = AgentPanelEntryKind::Terminal;
|
||||
assert!(
|
||||
matches!(panel.base_view, BaseView::Uninitialized),
|
||||
"precondition: the panel starts uninitialized"
|
||||
);
|
||||
assert!(
|
||||
panel.pending_terminal_spawn.is_none(),
|
||||
"precondition: no terminal spawn is in-flight yet"
|
||||
);
|
||||
panel.new_terminal(None, AgentThreadSource::AgentPanel, window, cx);
|
||||
let pending = panel.pending_terminal_spawn;
|
||||
assert!(
|
||||
pending.is_some(),
|
||||
"an explicit new terminal must mark a spawn in-flight"
|
||||
);
|
||||
panel.set_active(true, window, cx);
|
||||
assert_eq!(
|
||||
panel.pending_terminal_spawn, pending,
|
||||
"activating the panel while a terminal spawn is in-flight must not schedule a second (auto-init) terminal"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_restored_terminal_runs_init_command_once(cx: &mut TestAppContext) {
|
||||
let (panel, mut cx) = setup_panel(cx).await;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub mod thread_worktree_archive;
|
|||
|
||||
pub mod threads_archive_view;
|
||||
mod ui;
|
||||
mod unicode_confusables;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
|
|
|||
|
|
@ -525,18 +525,18 @@ impl CodegenAlternative {
|
|||
messages.push(user_message);
|
||||
|
||||
let tools = vec![
|
||||
LanguageModelRequestTool {
|
||||
name: REWRITE_SECTION_TOOL_NAME.to_string(),
|
||||
description: "Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
|
||||
input_schema: language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
|
||||
use_input_streaming: false,
|
||||
},
|
||||
LanguageModelRequestTool {
|
||||
name: FAILURE_MESSAGE_TOOL_NAME.to_string(),
|
||||
description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(),
|
||||
input_schema: language_model::tool_schema::root_schema_for::<FailureMessageInput>(tool_input_format).to_value(),
|
||||
use_input_streaming: false,
|
||||
},
|
||||
LanguageModelRequestTool::function(
|
||||
REWRITE_SECTION_TOOL_NAME.to_string(),
|
||||
"Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
|
||||
language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
|
||||
false,
|
||||
),
|
||||
LanguageModelRequestTool::function(
|
||||
FAILURE_MESSAGE_TOOL_NAME.to_string(),
|
||||
"Use this tool to provide a message to the user when you're unable to complete a task.".to_string(),
|
||||
language_model::tool_schema::root_schema_for::<FailureMessageInput>(tool_input_format).to_value(),
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
LanguageModelRequest {
|
||||
|
|
@ -1179,9 +1179,7 @@ impl CodegenAlternative {
|
|||
let mut chars_read_by_tool_id = chars_read_by_tool_id.lock();
|
||||
match tool_use.name.as_ref() {
|
||||
REWRITE_SECTION_TOOL_NAME => {
|
||||
let Ok(input) =
|
||||
serde_json::from_value::<RewriteSectionInput>(tool_use.input)
|
||||
else {
|
||||
let Ok(input) = tool_use.input.parse::<RewriteSectionInput>() else {
|
||||
return None;
|
||||
};
|
||||
let chars_read_so_far =
|
||||
|
|
@ -1198,9 +1196,7 @@ impl CodegenAlternative {
|
|||
})
|
||||
}
|
||||
FAILURE_MESSAGE_TOOL_NAME => {
|
||||
let Ok(mut input) =
|
||||
serde_json::from_value::<FailureMessageInput>(tool_use.input)
|
||||
else {
|
||||
let Ok(mut input) = tool_use.input.parse::<FailureMessageInput>() else {
|
||||
return None;
|
||||
};
|
||||
Some(ToolUseOutput::Failure(std::mem::take(&mut input.message)))
|
||||
|
|
@ -2011,7 +2007,9 @@ mod tests {
|
|||
id: id.into(),
|
||||
name: REWRITE_SECTION_TOOL_NAME.into(),
|
||||
raw_input: serde_json::to_string(&input).unwrap(),
|
||||
input: serde_json::to_value(&input).unwrap(),
|
||||
input: language_model::LanguageModelToolUseInput::Json(
|
||||
serde_json::to_value(&input).unwrap(),
|
||||
),
|
||||
is_input_complete: is_complete,
|
||||
thought_signature: None,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ use editor::scroll::Autoscroll;
|
|||
use editor::{
|
||||
Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
|
||||
};
|
||||
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
|
||||
use file_icons::FileIcons;
|
||||
use fs::Fs;
|
||||
use futures::FutureExt as _;
|
||||
|
|
@ -277,7 +276,7 @@ impl Conversation {
|
|||
let session_id = thread.read(cx).session_id().clone();
|
||||
let subscription = cx.subscribe(&thread, {
|
||||
let session_id = session_id.clone();
|
||||
move |this, _thread, event, cx| {
|
||||
move |this, _thread, event, _cx| {
|
||||
this.updated_at = Some(Instant::now());
|
||||
match event {
|
||||
AcpThreadEvent::ToolAuthorizationRequested(id) => {
|
||||
|
|
@ -294,15 +293,12 @@ impl Conversation {
|
|||
}
|
||||
}
|
||||
}
|
||||
AcpThreadEvent::ElicitationRequested(id)
|
||||
if cx.has_flag::<AcpBetaFeatureFlag>() =>
|
||||
{
|
||||
AcpThreadEvent::ElicitationRequested(id) => {
|
||||
this.elicitation_requests
|
||||
.entry(session_id.clone())
|
||||
.or_default()
|
||||
.push(id.clone());
|
||||
}
|
||||
AcpThreadEvent::ElicitationRequested(_) => {}
|
||||
AcpThreadEvent::ElicitationResponded(id) => {
|
||||
if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) {
|
||||
elicitations.retain(|elicitation_id| elicitation_id != id);
|
||||
|
|
@ -422,10 +418,6 @@ impl Conversation {
|
|||
response: acp::CreateElicitationResponse,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<()> {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let thread = self.threads.get(&session_id)?.clone();
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.respond_to_elicitation(&elicitation_id, response, cx);
|
||||
|
|
@ -1614,7 +1606,7 @@ impl ConversationView {
|
|||
});
|
||||
active.update(cx, |active, cx| {
|
||||
active.sync_elicitation_state_for_entry(index, window, cx);
|
||||
active.sync_editor_mode_for_empty_state(cx);
|
||||
active.sync_editor_mode(cx);
|
||||
active.sync_generating_indicator(cx);
|
||||
});
|
||||
}
|
||||
|
|
@ -1641,7 +1633,7 @@ impl ConversationView {
|
|||
entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
|
||||
list_state.splice(range.clone(), 0);
|
||||
active.update(cx, |active, cx| {
|
||||
active.sync_editor_mode_for_empty_state(cx);
|
||||
active.sync_editor_mode(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1652,10 +1644,9 @@ impl ConversationView {
|
|||
self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
|
||||
}
|
||||
AcpThreadEvent::ToolAuthorizationReceived(_) => {}
|
||||
AcpThreadEvent::ElicitationRequested(_) if cx.has_flag::<AcpBetaFeatureFlag>() => {
|
||||
AcpThreadEvent::ElicitationRequested(_) => {
|
||||
self.notify_with_sound("Waiting for input", IconName::Info, window, cx);
|
||||
}
|
||||
AcpThreadEvent::ElicitationRequested(_) => {}
|
||||
AcpThreadEvent::ElicitationResponded(_) => {}
|
||||
AcpThreadEvent::Retry(retry) => {
|
||||
if let Some(active) = self.thread_view(&session_id) {
|
||||
|
|
@ -2347,11 +2338,6 @@ impl ConversationView {
|
|||
}
|
||||
|
||||
fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
self.request_elicitation_form_states.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(store) = self.request_elicitation_store() else {
|
||||
self.request_elicitation_form_states.clear();
|
||||
return;
|
||||
|
|
@ -2397,10 +2383,6 @@ impl ConversationView {
|
|||
view: WeakEntity<Self>,
|
||||
cx: &App,
|
||||
) -> Vec<AnyElement> {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let Some(store) = connection.request_elicitations() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
|
@ -2529,10 +2511,6 @@ impl ConversationView {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(store) = self.request_elicitation_store() else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -2581,10 +2559,6 @@ impl ConversationView {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.respond_to_request_elicitation(
|
||||
elicitation_id,
|
||||
acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
|
||||
|
|
@ -2598,10 +2572,6 @@ impl ConversationView {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.respond_to_request_elicitation(
|
||||
elicitation_id,
|
||||
acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
|
||||
|
|
@ -3634,7 +3604,7 @@ pub(crate) mod tests {
|
|||
use agent_servers::FakeAcpAgentServer;
|
||||
use editor::MultiBufferOffset;
|
||||
use editor::actions::Paste;
|
||||
use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _};
|
||||
use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _};
|
||||
use fs::FakeFs;
|
||||
use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size};
|
||||
use parking_lot::Mutex;
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ use agent_settings::UserAgentsMd;
|
|||
use agent_skills::MAX_SKILL_DESCRIPTION_LEN;
|
||||
use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
|
||||
use editor::actions::OpenExcerpts;
|
||||
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
|
||||
use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
|
||||
|
||||
use crate::completion_provider::{AvailableSkill, PromptLocalCommand};
|
||||
use crate::message_editor::SharedSessionCapabilities;
|
||||
use crate::ui::{SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip};
|
||||
use crate::unicode_confusables;
|
||||
|
||||
use db::kvp::KeyValueStore;
|
||||
use gpui::List;
|
||||
|
|
@ -39,8 +39,8 @@ use language_model::{
|
|||
use notifications::status_toast::StatusToast;
|
||||
use settings::{update_settings_file, update_settings_file_with_completion};
|
||||
use ui::{
|
||||
ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle,
|
||||
Tab,
|
||||
ButtonLike, CalloutBorderPosition, Checkbox, SpinnerLabel, SpinnerVariant, SplitButton,
|
||||
SplitButtonStyle, Tab, ToggleState,
|
||||
};
|
||||
use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME};
|
||||
|
||||
|
|
@ -589,6 +589,10 @@ pub struct ThreadView {
|
|||
pub expanded_tool_call_raw_inputs: HashSet<acp::ToolCallId>,
|
||||
collapsed_sandbox_authorization_details: HashSet<acp::ToolCallId>,
|
||||
collapsed_sandbox_network_details: HashSet<acp::ToolCallId>,
|
||||
/// Sandbox escalation prompts whose "surprising Unicode" warning the user
|
||||
/// has explicitly acknowledged. Until a prompt's tool call is in this set,
|
||||
/// its allow buttons stay disabled. See [`Self::sandbox_confusable_findings`].
|
||||
acknowledged_confusable_warnings: HashSet<acp::ToolCallId>,
|
||||
pub subagent_scroll_handles: RefCell<HashMap<acp::SessionId, ScrollHandle>>,
|
||||
pub edits_expanded: bool,
|
||||
pub plan_expanded: bool,
|
||||
|
|
@ -996,6 +1000,7 @@ impl ThreadView {
|
|||
expanded_tool_call_raw_inputs: HashSet::default(),
|
||||
collapsed_sandbox_authorization_details: HashSet::default(),
|
||||
collapsed_sandbox_network_details: HashSet::default(),
|
||||
acknowledged_confusable_warnings: HashSet::default(),
|
||||
subagent_scroll_handles: RefCell::new(HashMap::default()),
|
||||
edits_expanded: false,
|
||||
plan_expanded: false,
|
||||
|
|
@ -1036,7 +1041,7 @@ impl ThreadView {
|
|||
};
|
||||
|
||||
this.sync_generating_indicator(cx);
|
||||
this.sync_editor_mode_for_empty_state(cx);
|
||||
this.sync_editor_mode(cx);
|
||||
this.sync_existing_elicitation_states(window, cx);
|
||||
let list_state_for_scroll = this.list_state.clone();
|
||||
let thread_view = cx.entity().downgrade();
|
||||
|
|
@ -2373,27 +2378,7 @@ impl ThreadView {
|
|||
|
||||
pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
|
||||
self.editor_expanded = is_expanded;
|
||||
self.message_editor.update(cx, |editor, cx| {
|
||||
if is_expanded {
|
||||
editor.set_mode(
|
||||
EditorMode::Full {
|
||||
scale_ui_elements_with_buffer_font_size: false,
|
||||
show_active_line_background: false,
|
||||
sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
let agent_settings = AgentSettings::get_global(cx);
|
||||
editor.set_mode(
|
||||
EditorMode::AutoHeight {
|
||||
min_lines: agent_settings.message_editor_min_lines,
|
||||
max_lines: Some(agent_settings.set_message_editor_max_lines()),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}
|
||||
});
|
||||
self.sync_editor_mode(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
|
@ -2506,13 +2491,40 @@ impl ThreadView {
|
|||
}
|
||||
|
||||
pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.pending_allow_blocked_by_confusables(cx) {
|
||||
return;
|
||||
}
|
||||
self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
|
||||
}
|
||||
|
||||
pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.pending_allow_blocked_by_confusables(cx) {
|
||||
return;
|
||||
}
|
||||
self.authorize_pending_with_granularity(true, window, cx);
|
||||
}
|
||||
|
||||
/// Whether the currently pending permission prompt is blocked by an
|
||||
/// unacknowledged surprising-Unicode warning, so the keyboard allow
|
||||
/// shortcuts must be ignored (mirroring the disabled allow buttons).
|
||||
fn pending_allow_blocked_by_confusables(&self, cx: &Context<Self>) -> bool {
|
||||
let session_id = self.thread.read(cx).session_id().clone();
|
||||
let Some((_, tool_call_id, _)) = self
|
||||
.conversation
|
||||
.read(cx)
|
||||
.pending_tool_call(&session_id, cx)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.thread.read(cx).entries().iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
AgentThreadEntry::ToolCall(call)
|
||||
if call.id == tool_call_id && self.sandbox_confusables_block_allow(call, cx)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.authorize_pending_with_granularity(false, window, cx);
|
||||
}
|
||||
|
|
@ -2538,26 +2550,18 @@ impl ThreadView {
|
|||
Some(())
|
||||
}
|
||||
|
||||
fn is_waiting_for_confirmation(&self, entry: &AgentThreadEntry, cx: &Context<Self>) -> bool {
|
||||
match entry {
|
||||
AgentThreadEntry::ToolCall(tool_call) => {
|
||||
matches!(
|
||||
tool_call.status,
|
||||
ToolCallStatus::WaitingForConfirmation { .. }
|
||||
)
|
||||
}
|
||||
AgentThreadEntry::Elicitation(elicitation_id) => {
|
||||
cx.has_flag::<AcpBetaFeatureFlag>()
|
||||
&& self
|
||||
.thread
|
||||
.read(cx)
|
||||
.elicitation(elicitation_id)
|
||||
.is_some_and(|(_, elicitation)| {
|
||||
fn has_pending_request_elicitation(&self, cx: &App) -> bool {
|
||||
self.server_view
|
||||
.read_with(cx, |server_view, cx| {
|
||||
server_view
|
||||
.request_elicitation_store()
|
||||
.is_some_and(|store| {
|
||||
store.read(cx).elicitations().iter().any(|elicitation| {
|
||||
matches!(elicitation.status, ElicitationStatus::Pending { .. })
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn sync_elicitation_state_for_entry(
|
||||
|
|
@ -2575,11 +2579,6 @@ impl ThreadView {
|
|||
elicitation_id.clone()
|
||||
};
|
||||
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
self.elicitation_form_states.remove(&elicitation_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let thread = self.thread.read(cx);
|
||||
let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| {
|
||||
(
|
||||
|
|
@ -2625,10 +2624,6 @@ impl ThreadView {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mode = self
|
||||
.thread
|
||||
.read(cx)
|
||||
|
|
@ -2674,10 +2669,6 @@ impl ThreadView {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.respond_to_elicitation(
|
||||
elicitation_id,
|
||||
acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
|
||||
|
|
@ -2691,10 +2682,6 @@ impl ThreadView {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !cx.has_flag::<AcpBetaFeatureFlag>() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.respond_to_elicitation(
|
||||
elicitation_id,
|
||||
acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
|
||||
|
|
@ -6030,9 +6017,8 @@ impl ThreadView {
|
|||
let rendered = this.render_entry(index, entries.len(), entry, window, cx);
|
||||
centered_container(rendered.into_any_element()).into_any_element()
|
||||
} else if this.generating_indicator_in_list {
|
||||
let confirmation = entries
|
||||
.last()
|
||||
.is_some_and(|entry| this.is_waiting_for_confirmation(entry, cx));
|
||||
let confirmation = this.thread.read(cx).is_waiting_for_confirmation()
|
||||
|| this.has_pending_request_elicitation(cx);
|
||||
let rendered = this.render_generating(confirmation, cx);
|
||||
centered_container(rendered.into_any_element()).into_any_element()
|
||||
} else {
|
||||
|
|
@ -6353,8 +6339,7 @@ impl ThreadView {
|
|||
}
|
||||
AgentThreadEntry::Elicitation(elicitation_id) => {
|
||||
let thread = self.thread.read(cx);
|
||||
if cx.has_flag::<AcpBetaFeatureFlag>()
|
||||
&& let Some((_, elicitation)) = thread.elicitation(elicitation_id)
|
||||
if let Some((_, elicitation)) = thread.elicitation(elicitation_id)
|
||||
&& should_render_elicitation(elicitation)
|
||||
{
|
||||
let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx);
|
||||
|
|
@ -6446,7 +6431,8 @@ impl ThreadView {
|
|||
primary
|
||||
};
|
||||
|
||||
let needs_confirmation = self.is_waiting_for_confirmation(entry, cx);
|
||||
let needs_confirmation = thread.read(cx).is_waiting_for_confirmation()
|
||||
|| self.has_pending_request_elicitation(cx);
|
||||
|
||||
let comments_editor = self.thread_feedback.comments_editor.clone();
|
||||
|
||||
|
|
@ -7104,11 +7090,21 @@ impl ThreadView {
|
|||
open_markdown_in_workspace(thread_title, markdown, workspace, window, cx)
|
||||
}
|
||||
|
||||
pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn sync_editor_mode(&mut self, cx: &mut Context<Self>) {
|
||||
let has_messages = self.list_state.item_count() > 0;
|
||||
let v2_empty_state = !has_messages;
|
||||
|
||||
let mode = if v2_empty_state {
|
||||
if !has_messages {
|
||||
self.editor_expanded = false;
|
||||
}
|
||||
|
||||
let mode = if self.editor_expanded {
|
||||
EditorMode::Full {
|
||||
scale_ui_elements_with_buffer_font_size: false,
|
||||
show_active_line_background: false,
|
||||
sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
|
||||
}
|
||||
} else if v2_empty_state {
|
||||
EditorMode::Full {
|
||||
scale_ui_elements_with_buffer_font_size: false,
|
||||
show_active_line_background: false,
|
||||
|
|
@ -7412,7 +7408,7 @@ impl ThreadView {
|
|||
block.markdown()
|
||||
}
|
||||
};
|
||||
md.map_or(false, |m| m.read(cx).selected_text().is_some())
|
||||
md.map_or(false, |m| m.read(cx).has_selection())
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
|
@ -7931,6 +7927,7 @@ impl ThreadView {
|
|||
})
|
||||
.when_some(confirmation_options, |this, options| {
|
||||
let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx);
|
||||
let allow_disabled = self.sandbox_confusables_block_allow(tool_call, cx);
|
||||
this.child(self.render_permission_buttons(
|
||||
self.thread.read(cx).session_id().clone(),
|
||||
is_first,
|
||||
|
|
@ -7938,6 +7935,7 @@ impl ThreadView {
|
|||
entry_ix,
|
||||
tool_call.id.clone(),
|
||||
focus_handle,
|
||||
allow_disabled,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
|
|
@ -7951,34 +7949,44 @@ impl ThreadView {
|
|||
reason: &SandboxNotAppliedReason,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let (title, detail): (SharedString, SharedString) = match reason {
|
||||
SandboxNotAppliedReason::ErrorLinuxWsl(error) => (
|
||||
"Couldn't create a sandbox".into(),
|
||||
error.user_facing_message().into(),
|
||||
),
|
||||
SandboxNotAppliedReason::DisabledForThisThread => {
|
||||
// The grant only exists because an earlier command failed to
|
||||
// create a sandbox; surface that same explanation here.
|
||||
let detail = self
|
||||
.find_thread_sandbox_error(cx)
|
||||
.map(|error| {
|
||||
SharedString::from(format!(
|
||||
"Allowed for this thread after the sandbox failed: {}",
|
||||
error.user_facing_message()
|
||||
))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"Unsandboxed execution is allowed for the rest of this thread.".into()
|
||||
});
|
||||
("Ran without sandbox".into(), detail)
|
||||
}
|
||||
};
|
||||
// (title, detail line, docs section slug)
|
||||
let (title, detail, docs_section): (SharedString, SharedString, Option<&'static str>) =
|
||||
match reason {
|
||||
SandboxNotAppliedReason::ErrorLinuxWsl(error) => (
|
||||
"Couldn't create a sandbox".into(),
|
||||
error.user_facing_message().into(),
|
||||
Some(error.docs_section()),
|
||||
),
|
||||
SandboxNotAppliedReason::DisabledForThisThread => {
|
||||
// The grant only exists because an earlier command failed to
|
||||
// create a sandbox; surface that same explanation here.
|
||||
let thread_error = self.find_thread_sandbox_error(cx);
|
||||
let detail = thread_error
|
||||
.as_ref()
|
||||
.map(|error| {
|
||||
SharedString::from(format!(
|
||||
"Allowed for this thread after the sandbox failed: {}",
|
||||
error.user_facing_message()
|
||||
))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"Unsandboxed execution is allowed for the rest of this thread.".into()
|
||||
});
|
||||
let docs_section = thread_error.as_ref().map(|error| error.docs_section());
|
||||
("Ran without sandbox".into(), detail, docs_section)
|
||||
}
|
||||
};
|
||||
|
||||
Callout::new()
|
||||
.severity(Severity::Warning)
|
||||
.icon(IconName::Warning)
|
||||
.title(title)
|
||||
.description(detail)
|
||||
.actions_slot(self.render_sandbox_docs_link(
|
||||
"sandbox-not-applied-docs-link",
|
||||
docs_section,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
|
@ -8126,7 +8134,13 @@ impl ThreadView {
|
|||
let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
|
||||
|
||||
let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
|
||||
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
|
||||
|
||||
let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
|
||||
|
||||
let has_content = !tool_call.content.is_empty()
|
||||
|| (should_show_raw_input && tool_call.raw_input.is_some());
|
||||
|
||||
let is_collapsible = has_content && !needs_confirmation;
|
||||
let mut is_open = self
|
||||
.entry_view_state
|
||||
.read(cx)
|
||||
|
|
@ -8134,8 +8148,6 @@ impl ThreadView {
|
|||
|
||||
is_open |= needs_confirmation;
|
||||
|
||||
let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
|
||||
|
||||
let input_output_header = |label: SharedString| {
|
||||
Label::new(label)
|
||||
.size(LabelSize::XSmall)
|
||||
|
|
@ -8173,6 +8185,7 @@ impl ThreadView {
|
|||
entry_ix,
|
||||
&tool_call.id,
|
||||
details,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
},
|
||||
|
|
@ -8281,6 +8294,7 @@ impl ThreadView {
|
|||
entry_ix,
|
||||
tool_call.id.clone(),
|
||||
focus_handle,
|
||||
self.sandbox_confusables_block_allow(tool_call, cx),
|
||||
cx,
|
||||
))
|
||||
.into_any()
|
||||
|
|
@ -8587,11 +8601,42 @@ impl ThreadView {
|
|||
.children(tool_output_display)
|
||||
}
|
||||
|
||||
/// A small "Learn more" link to the sandboxing docs, deep-linked to
|
||||
/// `section` when provided. Shared by the sandbox warning and the two
|
||||
/// sandbox approval prompts so the user can always reach an explanation of
|
||||
/// what they're being asked about.
|
||||
fn render_sandbox_docs_link(
|
||||
&self,
|
||||
id: &'static str,
|
||||
section: Option<&str>,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let url = zed_urls::sandboxing_docs(section, cx);
|
||||
let tooltip = format!("Opens {url}");
|
||||
// Wrap in a row so the button shrinks to its content width instead of
|
||||
// stretching to fill the enclosing column.
|
||||
h_flex()
|
||||
.child(
|
||||
Button::new(id, "Learn more")
|
||||
.label_size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.end_icon(
|
||||
Icon::new(IconName::ArrowUpRight)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XSmall),
|
||||
)
|
||||
.tooltip(Tooltip::text(tooltip))
|
||||
.on_click(move |_, _, cx| cx.open_url(&url)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_sandbox_authorization_details(
|
||||
&self,
|
||||
entry_ix: usize,
|
||||
tool_call_id: &acp::ToolCallId,
|
||||
details: &SandboxAuthorizationDetails,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let has_network = details.network_all_hosts || !details.network_hosts.is_empty();
|
||||
|
|
@ -8600,6 +8645,12 @@ impl ThreadView {
|
|||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let confusable_findings = if Self::confusable_warning_enabled(cx) {
|
||||
Self::sandbox_confusable_findings(details)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let network_section = has_network.then(|| {
|
||||
let summary = if details.network_all_hosts {
|
||||
"any host".to_string()
|
||||
|
|
@ -8827,10 +8878,186 @@ impl ThreadView {
|
|||
v_flex()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.when(!confusable_findings.is_empty(), |this| {
|
||||
this.child(self.render_sandbox_confusable_warning(
|
||||
tool_call_id,
|
||||
&confusable_findings,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
})
|
||||
.children(network_section)
|
||||
.children(write_section)
|
||||
.children(unsandboxed_section)
|
||||
.children(reason_section)
|
||||
.child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.child(self.render_sandbox_docs_link(
|
||||
"sandbox-authorization-docs-link",
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Scan the hosts and paths in a sandbox escalation request for surprising
|
||||
/// Unicode characters (homoglyphs, invisible characters, bidi overrides).
|
||||
/// Returns, for each offending value, the display string shown to the user
|
||||
/// and the distinct suspicious characters it contains. Hosts are decoded from
|
||||
/// Punycode first, so the display string is the Unicode form the user should
|
||||
/// scrutinize. Empty when nothing is surprising.
|
||||
fn sandbox_confusable_findings(
|
||||
details: &SandboxAuthorizationDetails,
|
||||
) -> Vec<(String, Vec<unicode_confusables::SuspiciousChar>)> {
|
||||
let mut findings = Vec::new();
|
||||
for host in &details.network_hosts {
|
||||
let (decoded, suspicious) = unicode_confusables::scan_host(host);
|
||||
if !suspicious.is_empty() {
|
||||
findings.push((decoded, suspicious));
|
||||
}
|
||||
}
|
||||
for path in &details.write_paths {
|
||||
let display = path.display().to_string();
|
||||
let suspicious = unicode_confusables::scan(&display);
|
||||
if !suspicious.is_empty() {
|
||||
findings.push((display, suspicious));
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// Whether the surprising-Unicode warning is enabled in settings (on by
|
||||
/// default). When off, prompts neither show the banner nor gate their allow
|
||||
/// buttons on it.
|
||||
fn confusable_warning_enabled(cx: &App) -> bool {
|
||||
AgentSettings::get_global(cx)
|
||||
.sandbox_permissions
|
||||
.warn_confusable_unicode
|
||||
}
|
||||
|
||||
/// Whether this tool call's sandbox escalation shows surprising Unicode that
|
||||
/// the user hasn't acknowledged yet. While true, the prompt's allow buttons
|
||||
/// stay disabled so the user can't grant access to a lookalike target
|
||||
/// without first ticking the acknowledgement checkbox.
|
||||
fn sandbox_confusables_block_allow(&self, tool_call: &ToolCall, cx: &App) -> bool {
|
||||
if !Self::confusable_warning_enabled(cx) {
|
||||
return false;
|
||||
}
|
||||
let Some(details) = tool_call.sandbox_authorization_details.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if self
|
||||
.acknowledged_confusable_warnings
|
||||
.contains(&tool_call.id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!Self::sandbox_confusable_findings(details).is_empty()
|
||||
}
|
||||
|
||||
/// Red banner warning that a requested domain or path contains surprising
|
||||
/// Unicode characters, with a checkbox the user must tick to unlock the
|
||||
/// allow buttons. See [`Self::sandbox_confusables_block_allow`].
|
||||
fn render_sandbox_confusable_warning(
|
||||
&self,
|
||||
tool_call_id: &acp::ToolCallId,
|
||||
findings: &[(String, Vec<unicode_confusables::SuspiciousChar>)],
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let acknowledged = self.acknowledged_confusable_warnings.contains(tool_call_id);
|
||||
let line_height = window.line_height();
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().status().error_border)
|
||||
.bg(cx.theme().status().error_background.opacity(0.15))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.items_start()
|
||||
.child(
|
||||
h_flex()
|
||||
.h(line_height)
|
||||
.flex_none()
|
||||
.justify_center()
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex().min_w_0().flex_1().gap_1().children(findings.iter().map(
|
||||
|(value, suspicious)| {
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new(format!(
|
||||
"“{value}” contains potentially surprising Unicode characters"
|
||||
))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Error),
|
||||
)
|
||||
.child(v_flex().min_w_0().pl_2().children(
|
||||
suspicious.iter().map(|character| {
|
||||
Label::new(format!("• {}", character.description()))
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx)
|
||||
}),
|
||||
))
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("configure-confusable-warning", IconName::Settings)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Configure unicode confusables warning"))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(zed_actions::OpenSettingsAt {
|
||||
path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(),
|
||||
target: None,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new(
|
||||
SharedString::from(format!("confusable-ack-{}", tool_call_id.0)),
|
||||
if acknowledged {
|
||||
ToggleState::Selected
|
||||
} else {
|
||||
ToggleState::Unselected
|
||||
},
|
||||
)
|
||||
.label("I understand and wish to proceed")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let tool_call_id = tool_call_id.clone();
|
||||
move |this, state: &ToggleState, _window, cx| {
|
||||
if *state == ToggleState::Selected {
|
||||
this.acknowledged_confusable_warnings
|
||||
.insert(tool_call_id.clone());
|
||||
} else {
|
||||
this.acknowledged_confusable_warnings.remove(&tool_call_id);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
|
@ -8866,7 +9093,12 @@ impl ThreadView {
|
|||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(Label::new(details.reason.clone()).size(LabelSize::Small)),
|
||||
.child(Label::new(details.reason.clone()).size(LabelSize::Small))
|
||||
.child(self.render_sandbox_docs_link(
|
||||
"sandbox-fallback-docs-link",
|
||||
details.docs_section.as_deref(),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
|
@ -8934,6 +9166,9 @@ impl ThreadView {
|
|||
entry_ix: usize,
|
||||
tool_call_id: acp::ToolCallId,
|
||||
focus_handle: &FocusHandle,
|
||||
// When true, the "allow" choices are disabled (e.g. an unacknowledged
|
||||
// surprising-Unicode warning is showing). "Deny"/"Retry" stay enabled.
|
||||
allow_disabled: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
match options {
|
||||
|
|
@ -8944,6 +9179,7 @@ impl ThreadView {
|
|||
entry_ix,
|
||||
tool_call_id,
|
||||
focus_handle,
|
||||
allow_disabled,
|
||||
cx,
|
||||
),
|
||||
PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown(
|
||||
|
|
@ -8954,6 +9190,7 @@ impl ThreadView {
|
|||
session_id,
|
||||
tool_call_id,
|
||||
focus_handle,
|
||||
allow_disabled,
|
||||
cx,
|
||||
),
|
||||
PermissionOptions::DropdownWithPatterns {
|
||||
|
|
@ -8968,6 +9205,7 @@ impl ThreadView {
|
|||
session_id,
|
||||
tool_call_id,
|
||||
focus_handle,
|
||||
allow_disabled,
|
||||
cx,
|
||||
),
|
||||
}
|
||||
|
|
@ -8982,6 +9220,7 @@ impl ThreadView {
|
|||
session_id: acp::SessionId,
|
||||
tool_call_id: acp::ToolCallId,
|
||||
focus_handle: &FocusHandle,
|
||||
allow_disabled: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
let selection = self.permission_selections.get(&tool_call_id);
|
||||
|
|
@ -9036,13 +9275,14 @@ impl ThreadView {
|
|||
.gap_0p5()
|
||||
.child(
|
||||
Button::new(("allow-btn", entry_ix), "Allow")
|
||||
.disabled(allow_disabled)
|
||||
.start_icon(
|
||||
Icon::new(IconName::Check)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Success),
|
||||
)
|
||||
.label_size(LabelSize::Small)
|
||||
.when(is_first, |this| {
|
||||
.when(is_first && !allow_disabled, |this| {
|
||||
this.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&AllowOnce as &dyn Action,
|
||||
|
|
@ -9368,6 +9608,7 @@ impl ThreadView {
|
|||
entry_ix: usize,
|
||||
tool_call_id: acp::ToolCallId,
|
||||
focus_handle: &FocusHandle,
|
||||
allow_disabled: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3, u8> = ArrayVec::new();
|
||||
|
|
@ -9431,13 +9672,22 @@ impl ThreadView {
|
|||
}
|
||||
};
|
||||
|
||||
let this = this.start_icon(icon);
|
||||
// An "allow" choice is disabled while a surprising-Unicode
|
||||
// warning is unacknowledged; "deny"/"retry" stay enabled.
|
||||
let is_allow = matches!(
|
||||
option.kind,
|
||||
acp::PermissionOptionKind::AllowOnce
|
||||
| acp::PermissionOptionKind::AllowAlways
|
||||
) && !is_retry;
|
||||
let disabled = allow_disabled && is_allow;
|
||||
|
||||
let this = this.start_icon(icon).disabled(disabled);
|
||||
|
||||
let Some(action) = action else {
|
||||
return this;
|
||||
};
|
||||
|
||||
if !is_first || seen_kinds.contains(&option.kind) {
|
||||
if !is_first || disabled || seen_kinds.contains(&option.kind) {
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -713,7 +713,6 @@ mod tests {
|
|||
use agent_client_protocol::schema::v1 as acp;
|
||||
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
|
||||
use editor::RowInfo;
|
||||
use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _};
|
||||
use fs::FakeFs;
|
||||
use gpui::{AppContext as _, TestAppContext};
|
||||
use parking_lot::RwLock;
|
||||
|
|
@ -851,11 +850,8 @@ mod tests {
|
|||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_hidden_elicitation_preserves_entry_index(cx: &mut TestAppContext) {
|
||||
async fn test_elicitation_preserves_entry_index(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project", json!({})).await;
|
||||
|
|
@ -900,7 +896,6 @@ mod tests {
|
|||
)),
|
||||
cx,
|
||||
);
|
||||
cx.update_flags(false, vec![]);
|
||||
});
|
||||
|
||||
let view_state = cx.new(|_cx| {
|
||||
|
|
|
|||
244
crates/agent_ui/src/unicode_confusables.rs
Normal file
244
crates/agent_ui/src/unicode_confusables.rs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
//! Detection of "surprising" Unicode characters in the domains and paths shown
|
||||
//! in sandbox privilege-escalation prompts.
|
||||
//!
|
||||
//! Homoglyph/confusable attacks (a Cyrillic `а` standing in for a Latin `a`),
|
||||
//! invisible characters (zero-width spaces), and bidirectional overrides can
|
||||
//! make a requested domain or path look like something it is not, tricking the
|
||||
//! user into granting access to the wrong target. Domains reach the prompt in
|
||||
//! Punycode (`xn--…`) ASCII form, so a lookalike host is decoded back to
|
||||
//! Unicode before scanning; paths are scanned as they are displayed.
|
||||
|
||||
use unicode_script::UnicodeScript as _;
|
||||
|
||||
/// Why a character in a domain or path is considered surprising.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SuspiciousKind {
|
||||
/// A bidirectional control that can visually reorder surrounding text (for
|
||||
/// example U+202E RIGHT-TO-LEFT OVERRIDE) — the classic "Trojan Source"
|
||||
/// trick.
|
||||
BidiControl,
|
||||
/// A zero-width, invisible, or non-ASCII whitespace formatting character.
|
||||
Invisible,
|
||||
/// A visible non-ASCII character that can be confused with ASCII (a
|
||||
/// homoglyph) or that mixes an unexpected script into otherwise-ASCII text.
|
||||
Confusable,
|
||||
}
|
||||
|
||||
/// A single surprising character discovered while scanning a domain or path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SuspiciousChar {
|
||||
pub character: char,
|
||||
pub kind: SuspiciousKind,
|
||||
}
|
||||
|
||||
impl SuspiciousChar {
|
||||
/// A human-readable, one-line description for the approval banner, such as
|
||||
/// `‘а’ (U+0430 Cyrillic)` or `U+202E right-to-left override`.
|
||||
pub fn description(&self) -> String {
|
||||
let codepoint = format!("U+{:04X}", self.character as u32);
|
||||
match self.kind {
|
||||
SuspiciousKind::Confusable => {
|
||||
format!(
|
||||
"‘{}’ ({codepoint} {})",
|
||||
self.character,
|
||||
self.character.script().full_name()
|
||||
)
|
||||
}
|
||||
// Bidi controls and invisible characters have no meaningful glyph to
|
||||
// show (and printing them could itself reorder the banner text), so
|
||||
// we render only the codepoint and a name.
|
||||
SuspiciousKind::BidiControl | SuspiciousKind::Invisible => {
|
||||
match well_known_name(self.character) {
|
||||
Some(name) => format!("{codepoint} {name}"),
|
||||
None => codepoint,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a raw string for surprising Unicode characters, returning each distinct
|
||||
/// offending character once, in order of first appearance.
|
||||
pub fn scan(text: &str) -> Vec<SuspiciousChar> {
|
||||
let mut result: Vec<SuspiciousChar> = Vec::new();
|
||||
for character in text.chars() {
|
||||
if character.is_ascii() {
|
||||
continue;
|
||||
}
|
||||
if result.iter().any(|found| found.character == character) {
|
||||
continue;
|
||||
}
|
||||
result.push(SuspiciousChar {
|
||||
character,
|
||||
kind: classify(character),
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Scan a host for surprising characters, first decoding any IDN/Punycode
|
||||
/// (`xn--…`) labels back to Unicode so a lookalike domain that reaches us as
|
||||
/// ASCII is still caught. Returns the decoded (Unicode) host — which is what the
|
||||
/// banner shows the user — alongside the findings. When nothing is surprising
|
||||
/// the returned host equals the input.
|
||||
pub fn scan_host(host: &str) -> (String, Vec<SuspiciousChar>) {
|
||||
// `domain_to_unicode` never fails destructively: on error it still returns a
|
||||
// best-effort decoding, which is exactly what we want to scan and show.
|
||||
let (decoded, _result) = idna::domain_to_unicode(host);
|
||||
let findings = scan(&decoded);
|
||||
(decoded, findings)
|
||||
}
|
||||
|
||||
fn classify(character: char) -> SuspiciousKind {
|
||||
if is_bidi_control(character) {
|
||||
SuspiciousKind::BidiControl
|
||||
} else if is_invisible(character) {
|
||||
SuspiciousKind::Invisible
|
||||
} else {
|
||||
SuspiciousKind::Confusable
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bidi_control(character: char) -> bool {
|
||||
matches!(character,
|
||||
'\u{061C}' // ARABIC LETTER MARK
|
||||
| '\u{200E}' // LEFT-TO-RIGHT MARK
|
||||
| '\u{200F}' // RIGHT-TO-LEFT MARK
|
||||
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
|
||||
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
|
||||
)
|
||||
}
|
||||
|
||||
fn is_invisible(character: char) -> bool {
|
||||
matches!(character,
|
||||
'\u{00AD}' // SOFT HYPHEN
|
||||
| '\u{180E}' // MONGOLIAN VOWEL SEPARATOR
|
||||
| '\u{200B}' // ZERO WIDTH SPACE
|
||||
| '\u{200C}' // ZERO WIDTH NON-JOINER
|
||||
| '\u{200D}' // ZERO WIDTH JOINER
|
||||
| '\u{2060}' // WORD JOINER
|
||||
| '\u{2061}'..='\u{2064}' // invisible math operators
|
||||
| '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM)
|
||||
) || is_non_ascii_space(character)
|
||||
// Any remaining control/format character (categories Cc/Cf) is
|
||||
// invisible for our purposes.
|
||||
|| character.is_control()
|
||||
}
|
||||
|
||||
fn is_non_ascii_space(character: char) -> bool {
|
||||
matches!(
|
||||
character,
|
||||
'\u{00A0}' // NO-BREAK SPACE
|
||||
| '\u{1680}' // OGHAM SPACE MARK
|
||||
| '\u{2000}'
|
||||
..='\u{200A}' // EN QUAD … HAIR SPACE
|
||||
| '\u{202F}' // NARROW NO-BREAK SPACE
|
||||
| '\u{205F}' // MEDIUM MATHEMATICAL SPACE
|
||||
| '\u{3000}' // IDEOGRAPHIC SPACE
|
||||
)
|
||||
}
|
||||
|
||||
/// Friendly names for the invisible/bidi characters most likely to show up in an
|
||||
/// attack, so the banner reads better than a bare codepoint.
|
||||
fn well_known_name(character: char) -> Option<&'static str> {
|
||||
Some(match character {
|
||||
'\u{00A0}' => "no-break space",
|
||||
'\u{00AD}' => "soft hyphen",
|
||||
'\u{061C}' => "arabic letter mark",
|
||||
'\u{180E}' => "mongolian vowel separator",
|
||||
'\u{200B}' => "zero-width space",
|
||||
'\u{200C}' => "zero-width non-joiner",
|
||||
'\u{200D}' => "zero-width joiner",
|
||||
'\u{200E}' => "left-to-right mark",
|
||||
'\u{200F}' => "right-to-left mark",
|
||||
'\u{202A}' => "left-to-right embedding",
|
||||
'\u{202B}' => "right-to-left embedding",
|
||||
'\u{202C}' => "pop directional formatting",
|
||||
'\u{202D}' => "left-to-right override",
|
||||
'\u{202E}' => "right-to-left override",
|
||||
'\u{2060}' => "word joiner",
|
||||
'\u{2066}' => "left-to-right isolate",
|
||||
'\u{2067}' => "right-to-left isolate",
|
||||
'\u{2068}' => "first strong isolate",
|
||||
'\u{2069}' => "pop directional isolate",
|
||||
'\u{3000}' => "ideographic space",
|
||||
'\u{FEFF}' => "zero-width no-break space",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plain_ascii_is_never_flagged() {
|
||||
assert!(scan("github.com").is_empty());
|
||||
assert!(scan("/home/user/project/src/main.rs").is_empty());
|
||||
assert!(scan("*.npmjs.org").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cyrillic_homoglyph() {
|
||||
// "gіthub.com" with a Cyrillic "і" (U+0456).
|
||||
let findings = scan("g\u{0456}thub.com");
|
||||
assert_eq!(findings.len(), 1);
|
||||
assert_eq!(findings[0].character, '\u{0456}');
|
||||
assert_eq!(findings[0].kind, SuspiciousKind::Confusable);
|
||||
assert!(findings[0].description().contains("U+0456"));
|
||||
assert!(findings[0].description().contains("Cyrillic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_bidi_override() {
|
||||
let findings = scan("safe\u{202E}txt.exe");
|
||||
assert_eq!(findings.len(), 1);
|
||||
assert_eq!(findings[0].kind, SuspiciousKind::BidiControl);
|
||||
assert_eq!(findings[0].description(), "U+202E right-to-left override");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_zero_width_space() {
|
||||
let findings = scan("git\u{200B}hub.com");
|
||||
assert_eq!(findings.len(), 1);
|
||||
assert_eq!(findings[0].kind, SuspiciousKind::Invisible);
|
||||
assert_eq!(findings[0].description(), "U+200B zero-width space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_repeated_characters() {
|
||||
// Two Cyrillic "а" (U+0430) should be reported once.
|
||||
let findings = scan("\u{0430}bc\u{0430}");
|
||||
assert_eq!(findings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_host_decodes_punycode_lookalike() {
|
||||
// "аpple.com" (leading Cyrillic а, U+0430) encodes to this Punycode.
|
||||
let (decoded, findings) = scan_host("xn--pple-43d.com");
|
||||
assert_eq!(decoded, "\u{0430}pple.com");
|
||||
assert_eq!(findings.len(), 1);
|
||||
assert_eq!(findings[0].character, '\u{0430}');
|
||||
assert_eq!(findings[0].kind, SuspiciousKind::Confusable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_host_leaves_plain_domains_alone() {
|
||||
let (decoded, findings) = scan_host("github.com");
|
||||
assert_eq!(decoded, "github.com");
|
||||
assert!(findings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_host_handles_wildcard_subdomain_patterns() {
|
||||
// Host patterns can carry a leading `*.` wildcard; decoding must not
|
||||
// choke on it, and a lookalike label behind it is still caught.
|
||||
let (_decoded, findings) = scan_host("*.xn--pple-43d.com");
|
||||
assert_eq!(findings.len(), 1);
|
||||
assert_eq!(findings[0].character, '\u{0430}');
|
||||
|
||||
let (decoded, findings) = scan_host("*.github.com");
|
||||
assert_eq!(decoded, "*.github.com");
|
||||
assert!(findings.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,14 @@ pub mod batches;
|
|||
pub mod completion;
|
||||
|
||||
pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com";
|
||||
const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01";
|
||||
pub const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01";
|
||||
|
||||
pub fn supports_fast_mode(model_id: &str) -> bool {
|
||||
matches!(
|
||||
model_id,
|
||||
"claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8"
|
||||
)
|
||||
}
|
||||
|
||||
pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5";
|
||||
pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8";
|
||||
|
|
@ -156,10 +163,7 @@ impl Model {
|
|||
AnthropicModelMode::Default
|
||||
};
|
||||
|
||||
let supports_speed = matches!(
|
||||
entry.id.as_str(),
|
||||
"claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8"
|
||||
);
|
||||
let supports_speed = supports_fast_mode(&entry.id);
|
||||
|
||||
// <https://platform.claude.com/docs/en/build-with-claude/compaction#supported-models>
|
||||
let supports_compaction = matches!(
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ use collections::HashMap;
|
|||
use futures::{Stream, StreamExt};
|
||||
use language_model_core::{
|
||||
CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
||||
LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice,
|
||||
LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, Role, StopReason,
|
||||
TokenUsage,
|
||||
LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput,
|
||||
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse,
|
||||
LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage,
|
||||
util::{fix_streamed_json, parse_tool_arguments},
|
||||
};
|
||||
use std::pin::Pin;
|
||||
|
|
@ -68,7 +68,7 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca
|
|||
}
|
||||
}
|
||||
|
||||
fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
|
||||
fn to_anthropic_content(content: MessageContent) -> Result<Option<RequestContent>> {
|
||||
match content {
|
||||
MessageContent::Text(text) => {
|
||||
let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
|
||||
|
|
@ -77,12 +77,12 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
|
|||
text
|
||||
};
|
||||
if !text.is_empty() {
|
||||
Some(RequestContent::Text {
|
||||
Ok(Some(RequestContent::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
MessageContent::Thinking {
|
||||
|
|
@ -92,36 +92,41 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
|
|||
if let Some(signature) = signature
|
||||
&& !thinking.is_empty()
|
||||
{
|
||||
Some(RequestContent::Thinking {
|
||||
Ok(Some(RequestContent::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
cache_control: None,
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
MessageContent::RedactedThinking(data) => {
|
||||
if !data.is_empty() {
|
||||
Some(RequestContent::RedactedThinking { data })
|
||||
Ok(Some(RequestContent::RedactedThinking { data }))
|
||||
} else {
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
MessageContent::Image(image) => Some(RequestContent::Image {
|
||||
MessageContent::Image(image) => Ok(Some(RequestContent::Image {
|
||||
source: ImageSource {
|
||||
source_type: "base64".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
data: image.source.to_string(),
|
||||
},
|
||||
cache_control: None,
|
||||
}),
|
||||
MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse {
|
||||
id: tool_use.id.to_string(),
|
||||
name: tool_use.name.to_string(),
|
||||
input: tool_use.input,
|
||||
cache_control: None,
|
||||
}),
|
||||
})),
|
||||
MessageContent::ToolUse(tool_use) => match tool_use.input {
|
||||
LanguageModelToolUseInput::Json(input) => Ok(Some(RequestContent::ToolUse {
|
||||
id: tool_use.id.to_string(),
|
||||
name: tool_use.name.to_string(),
|
||||
input,
|
||||
cache_control: None,
|
||||
})),
|
||||
LanguageModelToolUseInput::Text(_) => Err(anyhow::anyhow!(
|
||||
"Anthropic does not support custom tool calls"
|
||||
)),
|
||||
},
|
||||
MessageContent::ToolResult(tool_result) => {
|
||||
let content = match tool_result.content.as_slice() {
|
||||
[LanguageModelToolResultContent::Text(text)] => {
|
||||
|
|
@ -147,24 +152,24 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
|
|||
ToolResultContent::Multipart(parts)
|
||||
}
|
||||
};
|
||||
Some(RequestContent::ToolResult {
|
||||
Ok(Some(RequestContent::ToolResult {
|
||||
tool_use_id: tool_result.tool_use_id.to_string(),
|
||||
is_error: tool_result.is_error,
|
||||
content,
|
||||
cache_control: None,
|
||||
})
|
||||
}))
|
||||
}
|
||||
MessageContent::Compaction(CompactionContent::Summary { content }) => {
|
||||
Some(RequestContent::Compaction {
|
||||
Ok(Some(RequestContent::Compaction {
|
||||
content,
|
||||
cache_control: None,
|
||||
})
|
||||
}))
|
||||
}
|
||||
// Encrypted compaction blocks come from other providers, and a
|
||||
// Pending block is a streaming-only UI signal; neither is replayed.
|
||||
MessageContent::Compaction(
|
||||
CompactionContent::Encrypted { .. } | CompactionContent::Pending,
|
||||
) => None,
|
||||
) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +180,7 @@ pub fn into_anthropic(
|
|||
max_output_tokens: u64,
|
||||
mode: AnthropicModelMode,
|
||||
cache_mode: AnthropicPromptCacheMode,
|
||||
) -> crate::Request {
|
||||
) -> Result<crate::Request> {
|
||||
let mut new_messages: Vec<Message> = Vec::new();
|
||||
let mut system_message = String::new();
|
||||
let mut any_message_wants_cache = false;
|
||||
|
|
@ -189,11 +194,12 @@ pub fn into_anthropic(
|
|||
|
||||
match message.role {
|
||||
Role::User | Role::Assistant => {
|
||||
let mut anthropic_message_content: Vec<RequestContent> = message
|
||||
.content
|
||||
.into_iter()
|
||||
.filter_map(to_anthropic_content)
|
||||
.collect();
|
||||
let mut anthropic_message_content = Vec::new();
|
||||
for content in message.content {
|
||||
if let Some(content) = to_anthropic_content(content)? {
|
||||
anthropic_message_content.push(content);
|
||||
}
|
||||
}
|
||||
let anthropic_role = match message.role {
|
||||
Role::User => crate::Role::User,
|
||||
Role::Assistant => crate::Role::Assistant,
|
||||
|
|
@ -261,21 +267,29 @@ pub fn into_anthropic(
|
|||
let mut tools: Vec<Tool> = request
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| Tool {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.input_schema,
|
||||
eager_input_streaming: tool.use_input_streaming,
|
||||
cache_control: None,
|
||||
.map(|tool| match tool.input {
|
||||
LanguageModelRequestToolInput::Function {
|
||||
input_schema,
|
||||
use_input_streaming,
|
||||
} => Ok(Tool {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema,
|
||||
eager_input_streaming: use_input_streaming,
|
||||
cache_control: None,
|
||||
}),
|
||||
LanguageModelRequestToolInput::Custom { .. } => {
|
||||
Err(anyhow::anyhow!("Anthropic does not support custom tools"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<_>>()?;
|
||||
if let Some(cache_control) = long_lived_cache
|
||||
&& let Some(last_tool) = tools.last_mut()
|
||||
{
|
||||
last_tool.cache_control = Some(cache_control);
|
||||
}
|
||||
|
||||
crate::Request {
|
||||
Ok(crate::Request {
|
||||
model,
|
||||
messages: new_messages,
|
||||
max_tokens: max_output_tokens,
|
||||
|
|
@ -339,7 +353,7 @@ pub fn into_anthropic(
|
|||
trigger: Some(CompactionTrigger::InputTokens { value }),
|
||||
}],
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub struct AnthropicEventMapper {
|
||||
|
|
@ -451,7 +465,7 @@ impl AnthropicEventMapper {
|
|||
name: tool_use.name.clone().into(),
|
||||
is_input_complete: false,
|
||||
raw_input: tool_use.input_json.clone(),
|
||||
input,
|
||||
input: LanguageModelToolUseInput::Json(input),
|
||||
thought_signature: None,
|
||||
},
|
||||
))];
|
||||
|
|
@ -469,7 +483,7 @@ impl AnthropicEventMapper {
|
|||
id: tool_use.id.into(),
|
||||
name: tool_use.name.into(),
|
||||
is_input_complete: true,
|
||||
input,
|
||||
input: LanguageModelToolUseInput::Json(input),
|
||||
raw_input: tool_use.input_json.clone(),
|
||||
thought_signature: None,
|
||||
},
|
||||
|
|
@ -597,12 +611,12 @@ mod tests {
|
|||
intent: None,
|
||||
stop: vec![],
|
||||
temperature: None,
|
||||
tools: vec![language_model_core::LanguageModelRequestTool {
|
||||
name: "do_thing".into(),
|
||||
description: "Does a thing.".into(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
use_input_streaming: false,
|
||||
}],
|
||||
tools: vec![language_model_core::LanguageModelRequestTool::function(
|
||||
"do_thing".into(),
|
||||
"Does a thing.".into(),
|
||||
serde_json::json!({"type": "object"}),
|
||||
false,
|
||||
)],
|
||||
tool_choice: None,
|
||||
thinking_allowed: true,
|
||||
thinking_effort: None,
|
||||
|
|
@ -617,7 +631,8 @@ mod tests {
|
|||
4096,
|
||||
AnthropicModelMode::Default,
|
||||
AnthropicPromptCacheMode::Automatic,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// No message content block should carry cache_control anymore; the
|
||||
// conversation breakpoint is set via top-level automatic caching.
|
||||
|
|
@ -703,12 +718,12 @@ mod tests {
|
|||
intent: None,
|
||||
stop: vec![],
|
||||
temperature: None,
|
||||
tools: vec![language_model_core::LanguageModelRequestTool {
|
||||
name: "do_thing".into(),
|
||||
description: "Does a thing.".into(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
use_input_streaming: false,
|
||||
}],
|
||||
tools: vec![language_model_core::LanguageModelRequestTool::function(
|
||||
"do_thing".into(),
|
||||
"Does a thing.".into(),
|
||||
serde_json::json!({"type": "object"}),
|
||||
false,
|
||||
)],
|
||||
tool_choice: None,
|
||||
thinking_allowed: true,
|
||||
thinking_effort: None,
|
||||
|
|
@ -723,7 +738,8 @@ mod tests {
|
|||
4096,
|
||||
AnthropicModelMode::Default,
|
||||
AnthropicPromptCacheMode::Legacy,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(anthropic_request.cache_control.is_none());
|
||||
assert!(matches!(
|
||||
|
|
@ -781,7 +797,8 @@ mod tests {
|
|||
128_000,
|
||||
AnthropicModelMode::AdaptiveThinking,
|
||||
AnthropicPromptCacheMode::Automatic,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
anthropic_request
|
||||
|
|
@ -813,12 +830,12 @@ mod tests {
|
|||
intent: None,
|
||||
stop: vec![],
|
||||
temperature: None,
|
||||
tools: vec![language_model_core::LanguageModelRequestTool {
|
||||
name: "do_thing".into(),
|
||||
description: "Does a thing.".into(),
|
||||
input_schema: serde_json::json!({"type": "object"}),
|
||||
use_input_streaming: false,
|
||||
}],
|
||||
tools: vec![language_model_core::LanguageModelRequestTool::function(
|
||||
"do_thing".into(),
|
||||
"Does a thing.".into(),
|
||||
serde_json::json!({"type": "object"}),
|
||||
false,
|
||||
)],
|
||||
tool_choice: None,
|
||||
thinking_allowed: true,
|
||||
thinking_effort: None,
|
||||
|
|
@ -833,7 +850,8 @@ mod tests {
|
|||
4096,
|
||||
AnthropicModelMode::Default,
|
||||
AnthropicPromptCacheMode::Automatic,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(anthropic_request.cache_control.is_none());
|
||||
assert!(matches!(
|
||||
|
|
@ -879,6 +897,7 @@ mod tests {
|
|||
},
|
||||
AnthropicPromptCacheMode::Automatic,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -977,7 +996,8 @@ mod tests {
|
|||
4096,
|
||||
AnthropicModelMode::Default,
|
||||
AnthropicPromptCacheMode::Disabled,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&anthropic_request.context_management).unwrap(),
|
||||
|
|
|
|||
|
|
@ -69,6 +69,23 @@ pub fn skills_docs(cx: &App) -> String {
|
|||
format!("{docs_url}/ai/skills", docs_url = docs_url(cx))
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed's Agent sandboxing documentation.
|
||||
///
|
||||
/// Pass `section` to deep-link to a specific section anchor on the page (for
|
||||
/// example, `Some("installing-bubblewrap")`); pass `None` to link to the top of
|
||||
/// the page.
|
||||
///
|
||||
/// Unlike the account/app links above, this targets `zed.dev/docs` (via
|
||||
/// [`release_channel::docs_url`]) rather than the configured `server_url`: the
|
||||
/// docs are a static site hosted on `zed.dev`, so pointing at a local dev
|
||||
/// `server_url` would 404.
|
||||
pub fn sandboxing_docs(section: Option<&str>, cx: &App) -> String {
|
||||
let base = release_channel::docs_url("ai/sandboxing", cx);
|
||||
match section {
|
||||
Some(section) => format!("{base}#{section}"),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
pub fn llm_provider_docs(cx: &App) -> String {
|
||||
format!("{docs_url}/ai/llm-providers", docs_url = docs_url(cx))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ pub enum EditPredictionRejectReason {
|
|||
InterpolatedEmpty,
|
||||
/// Edits returned, but could not be interpolated after buffer changes
|
||||
InterpolateFailed,
|
||||
/// A patch was returned, but could not be applied to the buffer
|
||||
PatchApplyFailed,
|
||||
/// The new prediction was preferred over the current one
|
||||
Replaced,
|
||||
/// The current prediction was preferred over the new one
|
||||
|
|
|
|||
|
|
@ -7310,6 +7310,20 @@ async fn test_remote_git_branches(
|
|||
});
|
||||
|
||||
assert_eq!(host_branch.name(), "totally-new-branch");
|
||||
|
||||
let default_branch_b = cx_b
|
||||
.update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(false)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(default_branch_b.as_deref(), Some("main"));
|
||||
|
||||
let default_branch_with_remote_b = cx_b
|
||||
.update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(true)))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(default_branch_with_remote_b.as_deref(), Some("origin/main"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use std::{
|
|||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::table_data_engine::TableDataEngine;
|
||||
use crate::table_data_engine::{DisplayToDataMapping, TableDataEngine};
|
||||
use ui::{
|
||||
AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState,
|
||||
TableResizeBehavior, prelude::*,
|
||||
|
|
@ -41,6 +41,10 @@ pub struct CsvPreviewView {
|
|||
pub(crate) table_interaction_state: Entity<TableInteractionState>,
|
||||
pub(crate) column_widths: ColumnWidths,
|
||||
pub(crate) parsing_task: Option<Task<anyhow::Result<()>>>,
|
||||
pub(crate) is_parsing: bool,
|
||||
/// Background task computing the display-to-data mapping after a filter/sort change.
|
||||
/// Stored here so that a new change cancels the previous in-flight computation.
|
||||
pub(crate) filter_sort_task: Option<Task<()>>,
|
||||
pub(crate) settings: CsvPreviewSettings,
|
||||
/// Performance metrics for debugging and monitoring CSV operations.
|
||||
pub(crate) performance_metrics: PerformanceMetrics,
|
||||
|
|
@ -178,9 +182,11 @@ impl CsvPreviewView {
|
|||
table_interaction_state,
|
||||
column_widths: ColumnWidths::new(cx, 1),
|
||||
parsing_task: None,
|
||||
is_parsing: false,
|
||||
filter_sort_task: None,
|
||||
performance_metrics: PerformanceMetrics::default(),
|
||||
list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.))
|
||||
.measure_all(),
|
||||
.with_uniform_item_height(px(24.)),
|
||||
settings: CsvPreviewSettings::default(),
|
||||
last_parse_end_time: None,
|
||||
engine: TableDataEngine::default(),
|
||||
|
|
@ -194,22 +200,54 @@ impl CsvPreviewView {
|
|||
pub(crate) fn editor_state(&self) -> &EditorState {
|
||||
&self.active_editor_state
|
||||
}
|
||||
pub(crate) fn apply_sort(&mut self) {
|
||||
self.performance_metrics.record("Sort", || {
|
||||
self.engine.apply_sort();
|
||||
});
|
||||
pub(crate) fn apply_sort(&mut self, cx: &mut Context<Self>) {
|
||||
self.apply_filter_sort(cx);
|
||||
}
|
||||
|
||||
/// Update ordered indices when ordering or content changes
|
||||
pub(crate) fn apply_filter_sort(&mut self) {
|
||||
self.performance_metrics.record("Filter&sort", || {
|
||||
self.engine.calculate_d2d_mapping();
|
||||
});
|
||||
pub fn clear_filters(&mut self, col: types::AnyColumn, cx: &mut Context<Self>) {
|
||||
self.engine.clear_filters_for_col(col);
|
||||
self.apply_filter_sort(cx);
|
||||
}
|
||||
|
||||
// Update list state with filtered row count
|
||||
let visible_rows = self.engine.d2d_mapping().visible_row_count();
|
||||
self.list_state =
|
||||
gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all();
|
||||
pub fn toggle_filter(
|
||||
&mut self,
|
||||
col: types::AnyColumn,
|
||||
value: Option<SharedString>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Err(err) = self.engine.toggle_filter(col, value) {
|
||||
log::error!("Failed to toggle filter: {err}");
|
||||
return;
|
||||
}
|
||||
self.apply_filter_sort(cx);
|
||||
}
|
||||
|
||||
/// Spawns a background task to recompute the display-to-data mapping after a filter or sort
|
||||
/// change. Storing the task cancels any previous in-flight computation automatically.
|
||||
pub(crate) fn apply_filter_sort(&mut self, cx: &mut Context<Self>) {
|
||||
let contents = self.engine.contents.clone();
|
||||
let filter_stack = self.engine.filter_stack.clone();
|
||||
let sorting = self.engine.applied_sorting;
|
||||
|
||||
self.filter_sort_task = Some(cx.spawn(async move |this, cx| {
|
||||
let mapping = cx
|
||||
.background_spawn(async move {
|
||||
DisplayToDataMapping::compute(&contents, &filter_stack, sorting)
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |view, cx| {
|
||||
view.engine.set_d2d_mapping(mapping);
|
||||
let visible_rows = view.engine.d2d_mapping().visible_row_count();
|
||||
// Approximation of single csv table row height. Will be re-measured on scrolling.
|
||||
// This cheap solution allow to render scrollbar with fraction of a cost compared to `.measure_all()` call
|
||||
let approximate_height = px(24.);
|
||||
view.list_state
|
||||
.reset_with_uniform_height(visible_rows, approximate_height);
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn resolve_active_item_as_csv_editor(
|
||||
|
|
@ -301,7 +339,7 @@ impl PerformanceMetrics {
|
|||
.map(|(name, (duration, time))| {
|
||||
let took = duration.as_secs_f32() * 1000.;
|
||||
let ago = time.elapsed().as_secs();
|
||||
format!("{name}: {took:.2}ms {ago}s ago")
|
||||
format!("{name}: {took:.3}ms {ago}s ago")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
CsvPreviewView,
|
||||
types::TableLikeContent,
|
||||
|
|
@ -23,6 +25,7 @@ impl CsvPreviewView {
|
|||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let editor = self.active_editor_state.editor.clone();
|
||||
self.is_parsing = true;
|
||||
self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx));
|
||||
}
|
||||
|
||||
|
|
@ -80,11 +83,13 @@ impl CsvPreviewView {
|
|||
.insert("Parsing", (parse_duration, Instant::now()));
|
||||
|
||||
log::debug!("Parsed {} rows", parsed_csv.rows.len());
|
||||
view.engine.contents = parsed_csv;
|
||||
view.engine.contents = Arc::new(parsed_csv);
|
||||
view.engine.calculate_available_filters();
|
||||
view.sync_column_widths(cx);
|
||||
view.last_parse_end_time = Some(parse_end_time);
|
||||
|
||||
view.apply_filter_sort();
|
||||
view.is_parsing = false;
|
||||
view.apply_filter_sort(cx);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ impl CsvPreviewView {
|
|||
|
||||
let children = div()
|
||||
.absolute()
|
||||
.top_24()
|
||||
.bottom_8()
|
||||
.right_4()
|
||||
.px_3()
|
||||
.py_2()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use ui::{div, prelude::*};
|
||||
use ui::{SpinnerLabel, div, prelude::*};
|
||||
|
||||
use crate::CsvPreviewView;
|
||||
|
||||
|
|
@ -11,12 +11,12 @@ impl Render for CsvPreviewView {
|
|||
let render_prep_start = Instant::now();
|
||||
let table_with_settings = v_flex()
|
||||
.size_full()
|
||||
.p_4()
|
||||
.bg(theme.colors().editor_background)
|
||||
.track_focus(&self.focus_handle)
|
||||
.child(self.render_settings_panel(window, cx))
|
||||
.child({
|
||||
if self.engine.contents.number_of_cols == 0 {
|
||||
let is_parsing = self.is_parsing;
|
||||
if is_parsing || self.engine.contents.number_of_cols == 0 {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
|
|
@ -25,7 +25,15 @@ impl Render for CsvPreviewView {
|
|||
.text_ui(cx)
|
||||
.font_buffer(cx)
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child("No CSV content to display")
|
||||
.when(is_parsing, |div| {
|
||||
div.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(SpinnerLabel::new())
|
||||
.child("Loading…"),
|
||||
)
|
||||
})
|
||||
.when(!is_parsing, |div| div.child("No CSV content to display"))
|
||||
.into_any_element()
|
||||
} else {
|
||||
self.create_table(&self.column_widths.widths, cx)
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ impl CsvPreviewView {
|
|||
.px_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.h_full()
|
||||
.text_ui(cx)
|
||||
// Row identifiers are always centered
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ use ui::{
|
|||
IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex,
|
||||
};
|
||||
|
||||
use crate::{CsvPreviewView, settings::VerticalAlignment};
|
||||
use crate::{
|
||||
CsvPreviewView,
|
||||
settings::{FilterSortOrder, VerticalAlignment},
|
||||
};
|
||||
|
||||
///// Settings related /////
|
||||
impl CsvPreviewView {
|
||||
|
|
@ -18,6 +21,11 @@ impl CsvPreviewView {
|
|||
VerticalAlignment::Center => "Center",
|
||||
};
|
||||
|
||||
let current_filter_sort_text = match self.settings.filter_sort_order {
|
||||
FilterSortOrder::AlphaThenCount => "A-Z, then Count",
|
||||
FilterSortOrder::CountThenAlpha => "Count, then A-Z",
|
||||
};
|
||||
|
||||
let view = cx.entity();
|
||||
let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
menu.entry("Top", None, {
|
||||
|
|
@ -40,6 +48,27 @@ impl CsvPreviewView {
|
|||
})
|
||||
});
|
||||
|
||||
let filter_sort_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
menu.entry("A-Z, then Count", None, {
|
||||
let view = view.clone();
|
||||
move |_window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
this.settings.filter_sort_order = FilterSortOrder::AlphaThenCount;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.entry("Count, then A-Z", None, {
|
||||
let view = view.clone();
|
||||
move |_window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
this.settings.filter_sort_order = FilterSortOrder::CountThenAlpha;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let panel = h_flex()
|
||||
.gap_4()
|
||||
.p_2()
|
||||
|
|
@ -68,6 +97,28 @@ impl CsvPreviewView {
|
|||
"Choose vertical text alignment within cells",
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child("Filter Sort:"),
|
||||
)
|
||||
.child(
|
||||
DropdownMenu::new(
|
||||
ElementId::Name("filter-sort-order-dropdown".into()),
|
||||
current_filter_sort_text,
|
||||
filter_sort_dropdown_menu,
|
||||
)
|
||||
.trigger_size(ButtonSize::Compact)
|
||||
.trigger_tooltip(Tooltip::text(
|
||||
"Choose how filter values are sorted in the filter menu",
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
#[cfg(feature = "dev-tools")]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
use gpui::ElementId;
|
||||
use ui::{Tooltip, prelude::*};
|
||||
use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*};
|
||||
|
||||
use crate::{
|
||||
CsvPreviewView,
|
||||
table_data_engine::sorting_by_column::{AppliedSorting, SortDirection},
|
||||
settings::FilterSortOrder,
|
||||
table_data_engine::{
|
||||
filtering_by_column::{FilterEntry, FilterEntryState},
|
||||
sorting_by_column::{AppliedSorting, SortDirection},
|
||||
},
|
||||
types::AnyColumn,
|
||||
};
|
||||
|
||||
|
|
@ -22,7 +26,12 @@ impl CsvPreviewView {
|
|||
.w_full()
|
||||
.font_buffer(cx)
|
||||
.child(div().child(header_text))
|
||||
.child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(self.create_filter_button(cx, col_idx))
|
||||
.child(self.create_sort_button(cx, col_idx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
|
@ -82,9 +91,191 @@ impl CsvPreviewView {
|
|||
};
|
||||
|
||||
this.engine.applied_sorting = new_sorting;
|
||||
this.apply_sort();
|
||||
this.apply_sort(cx);
|
||||
cx.notify();
|
||||
}));
|
||||
sort_btn
|
||||
}
|
||||
|
||||
fn create_filter_button(
|
||||
&self,
|
||||
cx: &mut Context<'_, CsvPreviewView>,
|
||||
col: AnyColumn,
|
||||
) -> PopoverMenu<ContextMenu> {
|
||||
let has_active_filters = self.engine.has_active_filters(col);
|
||||
|
||||
PopoverMenu::new(ElementId::NamedInteger(
|
||||
"filter-menu".into(),
|
||||
col.get() as u64,
|
||||
))
|
||||
.trigger_with_tooltip(
|
||||
Button::new(
|
||||
ElementId::NamedInteger("filter-button".into(), col.get() as u64),
|
||||
if has_active_filters { "⛊" } else { "⛉" },
|
||||
)
|
||||
.size(ButtonSize::Compact)
|
||||
.style(if has_active_filters {
|
||||
ButtonStyle::Filled
|
||||
} else {
|
||||
ButtonStyle::Subtle
|
||||
}),
|
||||
Tooltip::text(if has_active_filters {
|
||||
"Column has active filters. Click to manage"
|
||||
} else {
|
||||
"No filters applied. Click to add filters"
|
||||
}),
|
||||
)
|
||||
.menu({
|
||||
let view_entity = cx.entity();
|
||||
move |window, cx| {
|
||||
let view = view_entity.read(cx);
|
||||
let column_filters = match view.engine.get_filters_for_column(col) {
|
||||
Ok(filters) => filters,
|
||||
Err(err) => {
|
||||
log::error!("Failed to get filters for column: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let filter_sort_order = view.settings.filter_sort_order;
|
||||
let filter_menu = Self::create_filter_menu(
|
||||
window,
|
||||
cx,
|
||||
view_entity.clone(),
|
||||
col,
|
||||
&column_filters,
|
||||
has_active_filters,
|
||||
filter_sort_order,
|
||||
);
|
||||
Some(filter_menu)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_filter_menu(
|
||||
window: &mut ui::Window,
|
||||
cx: &mut ui::App,
|
||||
view_entity: gpui::Entity<CsvPreviewView>,
|
||||
col: AnyColumn,
|
||||
column_filters: &[(FilterEntry, FilterEntryState)],
|
||||
has_active_filters: bool,
|
||||
sort_order: FilterSortOrder,
|
||||
) -> gpui::Entity<ContextMenu> {
|
||||
let mut available: Vec<&FilterEntry> = column_filters
|
||||
.iter()
|
||||
.filter_map(|(entry, state)| {
|
||||
matches!(state, FilterEntryState::Available { .. }).then_some(entry)
|
||||
})
|
||||
.collect();
|
||||
|
||||
match sort_order {
|
||||
FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| {
|
||||
a.content
|
||||
.cmp(&b.content)
|
||||
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
|
||||
}),
|
||||
FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| {
|
||||
b.occurred_times()
|
||||
.cmp(&a.occurred_times())
|
||||
.then_with(|| a.content.cmp(&b.content))
|
||||
}),
|
||||
}
|
||||
|
||||
let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters
|
||||
.iter()
|
||||
.filter_map(|(entry, state)| {
|
||||
if let FilterEntryState::Unavailable { blocked_by } = state {
|
||||
Some((entry, *blocked_by))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Pre-build applied-state lookup before moving into the closure
|
||||
let applied_states: Vec<(FilterEntry, bool)> = column_filters
|
||||
.iter()
|
||||
.filter_map(|(entry, state)| {
|
||||
if let FilterEntryState::Available { is_applied } = state {
|
||||
Some((entry.clone(), *is_applied))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let available_cloned: Vec<FilterEntry> = available.iter().map(|e| (*e).clone()).collect();
|
||||
let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable
|
||||
.into_iter()
|
||||
.map(|(e, col)| (e.clone(), col))
|
||||
.collect();
|
||||
|
||||
ContextMenu::build(window, cx, move |menu, _, _| {
|
||||
let mut menu = menu;
|
||||
|
||||
if has_active_filters {
|
||||
menu = menu
|
||||
.toggleable_entry("Clear all", false, ui::IconPosition::Start, None, {
|
||||
let view_entity = view_entity.clone();
|
||||
move |_window, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.clear_filters(col, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.separator();
|
||||
}
|
||||
|
||||
for entry in &available_cloned {
|
||||
let is_applied = applied_states
|
||||
.iter()
|
||||
.find(|(e, _)| e.content == entry.content)
|
||||
.map_or(false, |(_, applied)| *applied);
|
||||
|
||||
let label: SharedString =
|
||||
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
|
||||
let entry_value = entry.content.clone();
|
||||
|
||||
menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, {
|
||||
let view_entity = view_entity.clone();
|
||||
move |_window, cx| {
|
||||
view_entity.update(cx, |view, cx| {
|
||||
view.toggle_filter(col, entry_value.clone(), cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if !unavailable_cloned.is_empty() {
|
||||
menu = menu.separator().header("Hidden by other filters");
|
||||
for (entry, _blocked_by) in &unavailable_cloned {
|
||||
let label: SharedString =
|
||||
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
|
||||
menu = menu.custom_entry(
|
||||
{
|
||||
let label = label.clone();
|
||||
move |_window, cx| {
|
||||
div()
|
||||
.px_2()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child(label.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
menu
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_filter_label(content: Option<&SharedString>, count: usize) -> String {
|
||||
match content {
|
||||
Some(s) => format!("{s} ({count})"),
|
||||
None => format!("<null> ({count})"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
#[derive(Default, Clone, Copy, PartialEq)]
|
||||
pub enum RowRenderMechanism {
|
||||
/// More correct for multiline content, but slower.
|
||||
#[allow(dead_code)] // Will be used when settings ui is added
|
||||
#[default]
|
||||
VariableList,
|
||||
/// Default behaviour for now while resizable columns are being stabilized.
|
||||
#[default]
|
||||
#[allow(dead_code)] // Will be used when settings ui is added
|
||||
UniformList,
|
||||
}
|
||||
|
||||
|
|
@ -26,11 +26,21 @@ pub enum RowIdentifiers {
|
|||
RowNum,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, PartialEq)]
|
||||
pub enum FilterSortOrder {
|
||||
/// Sort alphabetically (A→Z), then by number of occurrences descending within ties
|
||||
#[default]
|
||||
AlphaThenCount,
|
||||
/// Sort by number of occurrences descending, then alphabetically within ties
|
||||
CountThenAlpha,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct CsvPreviewSettings {
|
||||
pub(crate) rendering_with: RowRenderMechanism,
|
||||
pub(crate) vertical_alignment: VerticalAlignment,
|
||||
pub(crate) numbering_type: RowIdentifiers,
|
||||
pub(crate) filter_sort_order: FilterSortOrder,
|
||||
pub(crate) show_debug_info: bool,
|
||||
#[cfg(feature = "dev-tools")]
|
||||
pub(crate) show_perf_metrics_overlay: bool,
|
||||
|
|
|
|||
|
|
@ -5,22 +5,32 @@
|
|||
//!
|
||||
//! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles.
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ui::table_row::TableRow;
|
||||
|
||||
use crate::{
|
||||
table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows},
|
||||
types::{DataRow, DisplayRow, TableCell, TableLikeContent},
|
||||
table_data_engine::{
|
||||
filtering_by_column::{FilterEntry, FilterStack, calculate_available_filters, retain_rows},
|
||||
sorting_by_column::{AppliedSorting, sort_data_rows},
|
||||
},
|
||||
types::{AnyColumn, DataRow, DisplayRow, TableCell, TableLikeContent},
|
||||
};
|
||||
|
||||
pub mod filtering_by_column;
|
||||
pub mod sorting_by_column;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TableDataEngine {
|
||||
pub filter_stack: FilterStack,
|
||||
/// Pre-computed unique values per column, used to populate filter menus
|
||||
all_filters: HashMap<AnyColumn, Vec<FilterEntry>>,
|
||||
pub applied_sorting: Option<AppliedSorting>,
|
||||
d2d_mapping: DisplayToDataMapping,
|
||||
pub contents: TableLikeContent,
|
||||
pub contents: Arc<TableLikeContent>,
|
||||
}
|
||||
|
||||
impl TableDataEngine {
|
||||
|
|
@ -28,32 +38,47 @@ impl TableDataEngine {
|
|||
&self.d2d_mapping
|
||||
}
|
||||
|
||||
pub(crate) fn apply_sort(&mut self) {
|
||||
self.d2d_mapping
|
||||
.apply_sorting(self.applied_sorting, &self.contents.rows);
|
||||
self.d2d_mapping.merge_mappings();
|
||||
pub(crate) fn set_d2d_mapping(&mut self, mapping: DisplayToDataMapping) {
|
||||
self.d2d_mapping = mapping;
|
||||
}
|
||||
|
||||
/// Applies sorting and filtering to the data and produces display to data mapping
|
||||
pub(crate) fn calculate_d2d_mapping(&mut self) {
|
||||
self.d2d_mapping
|
||||
.apply_sorting(self.applied_sorting, &self.contents.rows);
|
||||
self.d2d_mapping.merge_mappings();
|
||||
/// Recomputes the unique filter entries for every column from the current table data.
|
||||
/// Must be called after content changes (e.g. after parsing).
|
||||
pub fn calculate_available_filters(&mut self) {
|
||||
self.all_filters =
|
||||
calculate_available_filters(&self.contents.rows, self.contents.number_of_cols);
|
||||
}
|
||||
}
|
||||
|
||||
/// Relation of Display (rendered) rows to Data (src) rows with applied transformations
|
||||
/// Transformations applied:
|
||||
/// - sorting by column
|
||||
/// - filtering by column values
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DisplayToDataMapping {
|
||||
/// All rows sorted, regardless of applied filtering. Applied every time sorting changes
|
||||
/// All rows sorted, regardless of applied filtering. Recomputed every time sorting changes
|
||||
pub sorted_rows: Vec<DataRow>,
|
||||
/// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows`
|
||||
/// Rows that survive the active filters. Recomputed every time filters change
|
||||
pub retained_rows: HashSet<DataRow>,
|
||||
/// Merged result: sorted rows intersected with retained rows
|
||||
pub mapping: Arc<HashMap<DisplayRow, DataRow>>,
|
||||
}
|
||||
|
||||
impl DisplayToDataMapping {
|
||||
/// Computes the full display-to-data mapping from owned inputs.
|
||||
/// Intended to be called from a background thread.
|
||||
pub(crate) fn compute(
|
||||
contents: &Arc<TableLikeContent>,
|
||||
filter_stack: &FilterStack,
|
||||
sorting: Option<AppliedSorting>,
|
||||
) -> Self {
|
||||
let mut mapping = Self::default();
|
||||
mapping.apply_sorting(sorting, &contents.rows);
|
||||
mapping.apply_filtering(filter_stack, &contents.rows);
|
||||
mapping.merge_mappings();
|
||||
mapping
|
||||
}
|
||||
|
||||
/// Get the data row for a given display row
|
||||
pub fn get_data_row(&self, display_row: DisplayRow) -> Option<DataRow> {
|
||||
self.mapping.get(&display_row).copied()
|
||||
|
|
@ -77,11 +102,16 @@ impl DisplayToDataMapping {
|
|||
self.sorted_rows = sorted_rows;
|
||||
}
|
||||
|
||||
/// Take pre-computed sorting and filtering results, and apply them to the mapping
|
||||
fn apply_filtering(&mut self, filter_stack: &FilterStack, rows: &[TableRow<TableCell>]) {
|
||||
self.retained_rows = retain_rows(rows, filter_stack);
|
||||
}
|
||||
|
||||
/// Merges pre-computed sorting and filtering into the final display mapping
|
||||
fn merge_mappings(&mut self) {
|
||||
self.mapping = Arc::new(
|
||||
self.sorted_rows
|
||||
.iter()
|
||||
.filter(|data_row| self.retained_rows.contains(data_row))
|
||||
.enumerate()
|
||||
.map(|(display, data)| (DisplayRow(display), *data))
|
||||
.collect(),
|
||||
|
|
|
|||
250
crates/csv_preview/src/table_data_engine/filtering_by_column.rs
Normal file
250
crates/csv_preview/src/table_data_engine/filtering_by_column.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ui::{SharedString, table_row::TableRow};
|
||||
|
||||
use crate::{
|
||||
table_data_engine::TableDataEngine,
|
||||
types::{AnyColumn, DataRow, TableCell},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum FilterEntryState {
|
||||
Available { is_applied: bool },
|
||||
Unavailable { blocked_by: AnyColumn },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FilterEntry {
|
||||
/// Content to display. None if cell is virtual
|
||||
pub content: Option<SharedString>,
|
||||
/// List of rows in which this value occurs
|
||||
pub rows: Vec<DataRow>,
|
||||
}
|
||||
|
||||
impl FilterEntry {
|
||||
pub(crate) fn occurred_times(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct FilterStack {
|
||||
/// Columns in the order their first filter was applied, used to compute cascade availability
|
||||
activation_order: Vec<AnyColumn>,
|
||||
/// Which cell values are currently allowed for each filtered column
|
||||
retention_config: HashMap<AnyColumn, HashSet<Option<SharedString>>>,
|
||||
}
|
||||
|
||||
impl TableDataEngine {
|
||||
pub(crate) fn has_active_filters(&self, col: AnyColumn) -> bool {
|
||||
self.filter_stack.retention_config.contains_key(&col)
|
||||
}
|
||||
|
||||
/// Get available filters for a specific column with cascade behavior.
|
||||
///
|
||||
/// A filter entry is "unavailable" when all of its rows are hidden by a
|
||||
/// filter on an earlier-activated column, meaning selecting it would show
|
||||
/// zero rows. The cascade walk stops at `column` so that the column's own
|
||||
/// current filter does not affect its own entry availability.
|
||||
pub(crate) fn get_filters_for_column(
|
||||
&self,
|
||||
column: AnyColumn,
|
||||
) -> anyhow::Result<Arc<Vec<(FilterEntry, FilterEntryState)>>> {
|
||||
let all_column_entries = self
|
||||
.all_filters
|
||||
.get(&column)
|
||||
.ok_or_else(|| anyhow::anyhow!("Expected {column:?} to have filter entries"))?;
|
||||
|
||||
let mut unavailable_entries: HashMap<Option<SharedString>, AnyColumn> = HashMap::new();
|
||||
|
||||
for &column_applied_previously in &self.filter_stack.activation_order {
|
||||
if column_applied_previously == column {
|
||||
break;
|
||||
}
|
||||
|
||||
let retained_values = self
|
||||
.filter_stack
|
||||
.retention_config
|
||||
.get(&column_applied_previously)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Expected {column_applied_previously:?} to have retained entries \
|
||||
as it is present in the filter stack"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Rows that survive the filter on `column_applied_previously`
|
||||
let retained_rows: HashSet<DataRow> = self
|
||||
.contents
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| {
|
||||
let cell_value = row
|
||||
.get(column_applied_previously)
|
||||
.and_then(|cell| cell.display_value().cloned());
|
||||
retained_values.contains(&cell_value)
|
||||
})
|
||||
.map(|(index, _)| DataRow(index))
|
||||
.collect();
|
||||
|
||||
// An entry is unavailable when none of its rows survive the parent filter
|
||||
for entry in all_column_entries {
|
||||
if !entry.rows.iter().any(|row| retained_rows.contains(row)) {
|
||||
unavailable_entries.insert(entry.content.clone(), column_applied_previously);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let empty = HashSet::new();
|
||||
let active_column_filters = self
|
||||
.filter_stack
|
||||
.retention_config
|
||||
.get(&column)
|
||||
.unwrap_or(&empty);
|
||||
|
||||
Ok(Arc::new(
|
||||
all_column_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let state = if let Some(&blocked_by) = unavailable_entries.get(&entry.content) {
|
||||
FilterEntryState::Unavailable { blocked_by }
|
||||
} else {
|
||||
FilterEntryState::Available {
|
||||
is_applied: active_column_filters.contains(&entry.content),
|
||||
}
|
||||
};
|
||||
(entry.clone(), state)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn clear_filters_for_col(&mut self, col: AnyColumn) {
|
||||
self.filter_stack
|
||||
.activation_order
|
||||
.retain(|&entry| entry != col);
|
||||
self.filter_stack.retention_config.remove(&col);
|
||||
}
|
||||
|
||||
/// Toggle a filter value for a column. Returns `true` if the filter was
|
||||
/// added, `false` if it was removed.
|
||||
pub(crate) fn toggle_filter(
|
||||
&mut self,
|
||||
column: AnyColumn,
|
||||
value: Option<SharedString>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let is_currently_applied = self
|
||||
.filter_stack
|
||||
.retention_config
|
||||
.get(&column)
|
||||
.is_some_and(|filters| filters.contains(&value));
|
||||
|
||||
if is_currently_applied {
|
||||
self.remove_filter(column, value)?;
|
||||
Ok(false)
|
||||
} else {
|
||||
self.apply_filter(column, value);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_filter(
|
||||
&mut self,
|
||||
column: AnyColumn,
|
||||
value: Option<SharedString>,
|
||||
) -> anyhow::Result<()> {
|
||||
let entries = self
|
||||
.filter_stack
|
||||
.retention_config
|
||||
.get_mut(&column)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Expected {column:?} to be present in active filters")
|
||||
})?;
|
||||
|
||||
debug_assert!(
|
||||
entries.contains(&value),
|
||||
"Expected value to be present in {column:?} active filters"
|
||||
);
|
||||
|
||||
if entries.len() == 1 {
|
||||
self.filter_stack.retention_config.remove(&column);
|
||||
self.filter_stack
|
||||
.activation_order
|
||||
.retain(|&entry| entry != column);
|
||||
} else {
|
||||
entries.remove(&value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self, column: AnyColumn, value: Option<SharedString>) {
|
||||
// Track the column only on its first activation to preserve cascade order
|
||||
if !self.filter_stack.activation_order.contains(&column) {
|
||||
self.filter_stack.activation_order.push(column);
|
||||
}
|
||||
self.filter_stack
|
||||
.retention_config
|
||||
.entry(column)
|
||||
.or_default()
|
||||
.insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate available filter entries for each column from the table data.
|
||||
pub fn calculate_available_filters(
|
||||
content_rows: &[TableRow<TableCell>],
|
||||
number_of_cols: usize,
|
||||
) -> HashMap<AnyColumn, Vec<FilterEntry>> {
|
||||
let mut available_filters = HashMap::new();
|
||||
|
||||
for col_idx in 0..number_of_cols {
|
||||
let column = AnyColumn::new(col_idx);
|
||||
let mut value_to_rows: HashMap<Option<SharedString>, Vec<DataRow>> = HashMap::new();
|
||||
|
||||
for (row_index, row) in content_rows.iter().enumerate() {
|
||||
let cell_value = row
|
||||
.get(column)
|
||||
.and_then(|cell| cell.display_value().cloned());
|
||||
value_to_rows
|
||||
.entry(cell_value)
|
||||
.or_default()
|
||||
.push(DataRow(row_index));
|
||||
}
|
||||
|
||||
let filter_entries: Vec<FilterEntry> = value_to_rows
|
||||
.into_iter()
|
||||
.map(|(content, rows)| FilterEntry { content, rows })
|
||||
.collect();
|
||||
|
||||
available_filters.insert(column, filter_entries);
|
||||
}
|
||||
|
||||
available_filters
|
||||
}
|
||||
|
||||
/// Returns the set of data rows that survive all active filters in the stack.
|
||||
pub fn retain_rows(
|
||||
content_rows: &[TableRow<TableCell>],
|
||||
filter_stack: &FilterStack,
|
||||
) -> HashSet<DataRow> {
|
||||
let config = &filter_stack.retention_config;
|
||||
if config.is_empty() {
|
||||
return (0..content_rows.len()).map(DataRow).collect();
|
||||
}
|
||||
|
||||
content_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| {
|
||||
config.iter().all(|(col, allowed_values)| {
|
||||
let cell_value = row.get(*col).and_then(|cell| cell.display_value().cloned());
|
||||
allowed_values.contains(&cell_value)
|
||||
})
|
||||
})
|
||||
.map(|(index, _)| DataRow(index))
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -27,4 +27,4 @@ workspace = true
|
|||
|
||||
[[bin]]
|
||||
name = "docs_preprocessor"
|
||||
path = "src/main.rs"
|
||||
path = "src/main.rs"
|
||||
|
|
|
|||
549
crates/docs_preprocessor/src/ai_discovery.rs
Normal file
549
crates/docs_preprocessor/src/ai_discovery.rs
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
use anyhow::{Context, Result};
|
||||
use mdbook::BookItem;
|
||||
use mdbook::book::Book;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::FRONT_MATTER_COMMENT;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DocsPage {
|
||||
section: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
pub(crate) source_path: PathBuf,
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub(crate) fn write_ai_discovery_artifacts(
|
||||
pages: &[DocsPage],
|
||||
destination: &Path,
|
||||
site_url: &str,
|
||||
) -> Result<()> {
|
||||
copy_markdown_sources(destination, site_url, pages)?;
|
||||
write_llms_txt(destination, site_url, pages)?;
|
||||
write_sitemap_xml(destination, site_url, pages)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn docs_pages(book: &Book) -> Result<Vec<DocsPage>> {
|
||||
let mut pages = Vec::new();
|
||||
let mut section = "Docs".to_string();
|
||||
for item in book.iter() {
|
||||
let BookItem::Chapter(chapter) = item else {
|
||||
if let BookItem::PartTitle(part_title) = item {
|
||||
section.clone_from(part_title);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(source_path) = chapter.source_path.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if source_path == Path::new("SUMMARY.md") {
|
||||
continue;
|
||||
}
|
||||
pages.push(DocsPage {
|
||||
section: section.clone(),
|
||||
title: chapter.name.clone(),
|
||||
description: docs_page_description(&chapter.content),
|
||||
source_path: source_path.clone(),
|
||||
content: chapter.content.clone(),
|
||||
});
|
||||
}
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> {
|
||||
for page in pages {
|
||||
let destination = destination.join(&page.source_path);
|
||||
if let Some(parent) = destination.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create markdown destination {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let contents = rewrite_docs_links(&markdown_source_contents(&page.content), site_url);
|
||||
std::fs::write(
|
||||
&destination,
|
||||
add_llms_markdown_directive(&contents, site_url),
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to write markdown page {} to {}",
|
||||
page.source_path.display(),
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let getting_started = destination.join("getting-started.md");
|
||||
if getting_started.exists() {
|
||||
std::fs::copy(&getting_started, destination.join("index.md"))
|
||||
.context("failed to write index.md markdown alias")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn markdown_source_contents(contents: &str) -> String {
|
||||
front_matter_comment_regex()
|
||||
.replace(contents, "")
|
||||
.trim_start()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn docs_page_description(contents: &str) -> Option<String> {
|
||||
docs_page_metadata(contents).and_then(|metadata| {
|
||||
metadata
|
||||
.get("description")
|
||||
.map(|description| {
|
||||
description
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.filter(|description| !description.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn docs_page_metadata(contents: &str) -> Option<HashMap<String, String>> {
|
||||
let captures = front_matter_comment_regex().captures(contents)?;
|
||||
serde_json::from_str(&captures[1]).ok()
|
||||
}
|
||||
|
||||
fn front_matter_comment_regex() -> &'static Regex {
|
||||
static FRONT_MATTER_COMMENT_REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
FRONT_MATTER_COMMENT_REGEX
|
||||
.get_or_init(|| Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "([^\\n]*)")).unwrap())
|
||||
}
|
||||
|
||||
fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> {
|
||||
let mut contents = String::new();
|
||||
contents.push_str("# Zed Docs\n\n");
|
||||
contents.push_str(
|
||||
"> Official Zed documentation index with links to Markdown versions of each docs page.\n\n",
|
||||
);
|
||||
contents.push_str(
|
||||
"Use these links for concise Markdown copies of Zed documentation pages. Each linked page mirrors the corresponding `/docs/*.html` page without site navigation or styling.\n\n",
|
||||
);
|
||||
let mut current_section = None;
|
||||
for page in pages {
|
||||
if current_section != Some(page.section.as_str()) {
|
||||
if current_section.is_some() {
|
||||
contents.push('\n');
|
||||
}
|
||||
contents.push_str("## ");
|
||||
contents.push_str(&markdown_text(&page.section));
|
||||
contents.push_str("\n\n");
|
||||
current_section = Some(page.section.as_str());
|
||||
}
|
||||
contents.push_str("- [");
|
||||
contents.push_str(&markdown_text(&page.title));
|
||||
contents.push_str("](");
|
||||
contents.push_str(&absolute_docs_url(site_url, &page.source_path));
|
||||
contents.push(')');
|
||||
if let Some(description) = &page.description {
|
||||
contents.push_str(": ");
|
||||
contents.push_str(&markdown_text(description));
|
||||
}
|
||||
contents.push('\n');
|
||||
}
|
||||
std::fs::write(destination.join("llms.txt"), contents).context("failed to write llms.txt")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn markdown_text(text: &str) -> String {
|
||||
text.replace('\\', "\\\\")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]")
|
||||
}
|
||||
|
||||
fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> {
|
||||
let mut contents = String::new();
|
||||
contents.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
contents.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
||||
for page in pages {
|
||||
contents.push_str(" <url><loc>");
|
||||
contents.push_str(&xml_escape(&absolute_docs_url(
|
||||
site_url,
|
||||
&page.source_path.with_extension("html"),
|
||||
)));
|
||||
contents.push_str("</loc>");
|
||||
contents.push_str("</url>\n");
|
||||
}
|
||||
contents.push_str("</urlset>\n");
|
||||
std::fs::write(destination.join("sitemap.xml"), contents)
|
||||
.context("failed to write sitemap.xml")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_pages_redirects(
|
||||
destination: &Path,
|
||||
redirects: &[(String, String)],
|
||||
site_url: &str,
|
||||
) -> Result<()> {
|
||||
let Some(deploy_root) = destination.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut contents = String::new();
|
||||
for (source, destination) in redirects {
|
||||
write_redirect_line(
|
||||
&mut contents,
|
||||
&docs_path("/docs/", source),
|
||||
&redirect_destination(site_url, destination),
|
||||
);
|
||||
if let Some(extensionless_source) = strip_html_suffix(source) {
|
||||
write_redirect_line(
|
||||
&mut contents,
|
||||
&docs_path("/docs/", &extensionless_source),
|
||||
&redirect_destination(
|
||||
site_url,
|
||||
&strip_html_suffix(destination).unwrap_or_else(|| destination.to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(markdown_source) = html_path_to_markdown(source) {
|
||||
if let Some(markdown_destination) = html_path_to_markdown(destination) {
|
||||
write_redirect_line(
|
||||
&mut contents,
|
||||
&docs_path("/docs/", &markdown_source),
|
||||
&redirect_destination(site_url, &markdown_destination),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::write(deploy_root.join("_redirects"), contents)
|
||||
.context("failed to write Cloudflare Pages _redirects")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_markdown_redirect_aliases(
|
||||
destination: &Path,
|
||||
redirects: &[(String, String)],
|
||||
site_url: &str,
|
||||
) -> Result<()> {
|
||||
for (source, redirect_destination_path) in redirects {
|
||||
let Some(source_markdown) = html_path_to_markdown(source) else {
|
||||
continue;
|
||||
};
|
||||
let Some(destination_markdown) = html_path_to_markdown(redirect_destination_path) else {
|
||||
continue;
|
||||
};
|
||||
let source_markdown = destination.join(source_markdown.trim_start_matches('/'));
|
||||
let destination_markdown =
|
||||
destination.join(destination_markdown.trim_start_matches("/docs/"));
|
||||
if !destination_markdown.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = source_markdown.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"failed to create markdown alias directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let contents = format!(
|
||||
"# Moved\n\n> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\nThis page moved to [the current docs page]({}).\n",
|
||||
docs_url(site_url, Path::new("llms.txt")),
|
||||
html_path_to_markdown(redirect_destination_path)
|
||||
.map(|path| redirect_destination(site_url, &path))
|
||||
.unwrap_or_else(|| redirect_destination(site_url, redirect_destination_path))
|
||||
);
|
||||
std::fs::write(&source_markdown, contents).with_context(|| {
|
||||
format!(
|
||||
"failed to write markdown redirect alias from {} to {}",
|
||||
redirect_destination_path,
|
||||
source_markdown.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_redirect_line(contents: &mut String, source: &str, destination: &str) {
|
||||
contents.push_str(source);
|
||||
contents.push(' ');
|
||||
contents.push_str(destination);
|
||||
contents.push_str(" 301\n");
|
||||
}
|
||||
|
||||
fn docs_path(site_url: &str, path: &str) -> String {
|
||||
docs_url(site_url, Path::new(path.trim_start_matches('/')))
|
||||
}
|
||||
|
||||
fn redirect_destination(site_url: &str, destination: &str) -> String {
|
||||
if let Some(path) = destination.strip_prefix("/docs/") {
|
||||
docs_url(site_url, Path::new(path))
|
||||
} else if destination == "/docs" {
|
||||
docs_url(site_url, Path::new(""))
|
||||
} else {
|
||||
destination.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_html_suffix(path: &str) -> Option<String> {
|
||||
let (path, fragment) = split_fragment(path);
|
||||
let path = path.strip_suffix(".html")?;
|
||||
Some(format!("{path}{fragment}"))
|
||||
}
|
||||
|
||||
fn html_path_to_markdown(path: &str) -> Option<String> {
|
||||
let (path, fragment) = split_fragment(path);
|
||||
if !path.starts_with("/docs/") && path != "/docs" && !path.ends_with(".html") {
|
||||
return None;
|
||||
}
|
||||
let markdown_path = path.strip_suffix(".html").unwrap_or(path);
|
||||
Some(format!("{markdown_path}.md{fragment}"))
|
||||
}
|
||||
|
||||
fn split_fragment(path: &str) -> (&str, &str) {
|
||||
match path.find('#') {
|
||||
Some(index) => (&path[..index], &path[index..]),
|
||||
None => (path, ""),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite_docs_links(contents: &str, site_url: &str) -> String {
|
||||
const STABLE_DOCS_PREFIX: &str = "https://zed.dev/docs/";
|
||||
let channel_docs_prefix = absolute_docs_url(site_url, Path::new(""));
|
||||
if channel_docs_prefix == STABLE_DOCS_PREFIX {
|
||||
return contents.to_string();
|
||||
}
|
||||
|
||||
let mut output = String::with_capacity(contents.len());
|
||||
let mut remaining = contents;
|
||||
while let Some(index) = remaining.find(STABLE_DOCS_PREFIX) {
|
||||
output.push_str(&remaining[..index]);
|
||||
let after_prefix = &remaining[index + STABLE_DOCS_PREFIX.len()..];
|
||||
if after_prefix.starts_with("preview/") || after_prefix.starts_with("nightly/") {
|
||||
output.push_str(STABLE_DOCS_PREFIX);
|
||||
} else {
|
||||
output.push_str(&channel_docs_prefix);
|
||||
}
|
||||
remaining = after_prefix;
|
||||
}
|
||||
output.push_str(remaining);
|
||||
output
|
||||
}
|
||||
|
||||
pub(crate) fn add_markdown_alternate_link(
|
||||
contents: &str,
|
||||
html_file: &Path,
|
||||
root_dir: &Path,
|
||||
site_url: &str,
|
||||
) -> String {
|
||||
let Ok(relative_path) = html_file.strip_prefix(root_dir) else {
|
||||
return contents.to_string();
|
||||
};
|
||||
let markdown_path = relative_path.with_extension("md");
|
||||
if !root_dir.join(&markdown_path).exists() {
|
||||
return contents.to_string();
|
||||
}
|
||||
let markdown_url = docs_url(site_url, &markdown_path);
|
||||
let link = format!(
|
||||
" <link rel=\"alternate\" type=\"text/markdown\" href=\"{}\">\n",
|
||||
markdown_url
|
||||
);
|
||||
contents.replacen("</head>", &(link + " </head>"), 1)
|
||||
}
|
||||
|
||||
fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String {
|
||||
let directive = format!(
|
||||
"> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\n",
|
||||
docs_url(site_url, Path::new("llms.txt")),
|
||||
);
|
||||
if let Some(rest) = contents.strip_prefix("---\n") {
|
||||
if let Some(frontmatter_end) = rest.find("\n---\n") {
|
||||
let split_at = "---\n".len() + frontmatter_end + "\n---\n".len();
|
||||
let mut output = String::with_capacity(contents.len() + directive.len());
|
||||
output.push_str(&contents[..split_at]);
|
||||
output.push('\n');
|
||||
output.push_str(&directive);
|
||||
output.push_str(&contents[split_at..]);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = String::with_capacity(contents.len() + directive.len());
|
||||
output.push_str(&directive);
|
||||
output.push_str(contents);
|
||||
output
|
||||
}
|
||||
|
||||
fn docs_url(site_url: &str, path: &Path) -> String {
|
||||
let mut url = site_url.to_string();
|
||||
if !url.ends_with('/') {
|
||||
url.push('/');
|
||||
}
|
||||
url.push_str(&path.to_string_lossy().replace('\\', "/"));
|
||||
url
|
||||
}
|
||||
|
||||
fn absolute_docs_url(site_url: &str, path: &Path) -> String {
|
||||
let url = docs_url(site_url, path);
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
url
|
||||
} else {
|
||||
format!("https://zed.dev{}", url)
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_add_llms_markdown_directive_inserts_after_frontmatter() {
|
||||
let contents = "---\ntitle: Example\n---\n# Example\n";
|
||||
let output = add_llms_markdown_directive(contents, "/docs/");
|
||||
|
||||
assert!(output.starts_with("---\ntitle: Example\n---\n\n"));
|
||||
assert!(output.contains(
|
||||
"> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt)."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redirect_destination_uses_channel_site_url_for_docs_paths() {
|
||||
assert_eq!(
|
||||
redirect_destination("/docs/preview/", "/docs/ai/overview.html"),
|
||||
"/docs/preview/ai/overview.html"
|
||||
);
|
||||
assert_eq!(
|
||||
redirect_destination("/docs/preview/", "/community-links"),
|
||||
"/community-links"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_docs_links_uses_channel_site_url() {
|
||||
assert_eq!(
|
||||
rewrite_docs_links(
|
||||
"See [Code Actions](https://zed.dev/docs/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html).",
|
||||
"/docs/preview/"
|
||||
),
|
||||
"See [Code Actions](https://zed.dev/docs/preview/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html)."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docs_path_uses_channel_site_url() {
|
||||
assert_eq!(
|
||||
docs_path("/docs/preview/", "/assistant.md"),
|
||||
"/docs/preview/assistant.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_pages_redirects_keeps_sources_on_internal_pages_path() -> Result<()> {
|
||||
let deploy_root = std::env::temp_dir().join(format!(
|
||||
"docs_preprocessor_pages_redirects_test_{}_{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_nanos()
|
||||
));
|
||||
let destination = deploy_root.join("docs");
|
||||
std::fs::create_dir_all(&destination)?;
|
||||
let redirects = vec![
|
||||
(
|
||||
"/assistant.html".to_string(),
|
||||
"/docs/ai/overview.html".to_string(),
|
||||
),
|
||||
(
|
||||
"/community/feedback.html".to_string(),
|
||||
"/community-links".to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
write_pages_redirects(&destination, &redirects, "/docs/preview/")?;
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(deploy_root.join("_redirects"))?,
|
||||
"/docs/assistant.html /docs/preview/ai/overview.html 301\n\
|
||||
/docs/assistant /docs/preview/ai/overview 301\n\
|
||||
/docs/assistant.md /docs/preview/ai/overview.md 301\n\
|
||||
/docs/community/feedback.html /community-links 301\n\
|
||||
/docs/community/feedback /community-links 301\n"
|
||||
);
|
||||
std::fs::remove_dir_all(&deploy_root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_ai_discovery_artifacts_generates_agent_facing_metadata() -> Result<()> {
|
||||
let destination = std::env::temp_dir().join(format!(
|
||||
"docs_preprocessor_ai_discovery_test_{}_{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&destination)?;
|
||||
|
||||
let pages = vec![
|
||||
DocsPage {
|
||||
section: "Docs".to_string(),
|
||||
title: "Getting Started".to_string(),
|
||||
description: Some("Start using Zed.".to_string()),
|
||||
source_path: PathBuf::from("getting-started.md"),
|
||||
content: format!(
|
||||
"{}\n# Getting Started\n",
|
||||
FRONT_MATTER_COMMENT.replace("{}", r#"{"description":"Start using Zed."}"#)
|
||||
),
|
||||
},
|
||||
DocsPage {
|
||||
section: "AI".to_string(),
|
||||
title: "MCP".to_string(),
|
||||
description: Some("Connect model context servers.".to_string()),
|
||||
source_path: PathBuf::from("ai/mcp.md"),
|
||||
content: format!(
|
||||
"{}\n# MCP\n",
|
||||
FRONT_MATTER_COMMENT
|
||||
.replace("{}", r#"{"description":"Connect model context servers."}"#)
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
write_ai_discovery_artifacts(&pages, &destination, "/docs/")?;
|
||||
|
||||
let llms_txt = std::fs::read_to_string(destination.join("llms.txt"))?;
|
||||
assert!(llms_txt.contains("## Docs"));
|
||||
assert!(llms_txt.contains(
|
||||
"- [Getting Started](https://zed.dev/docs/getting-started.md): Start using Zed."
|
||||
));
|
||||
assert!(llms_txt.contains("## AI"));
|
||||
assert!(
|
||||
llms_txt.contains(
|
||||
"- [MCP](https://zed.dev/docs/ai/mcp.md): Connect model context servers."
|
||||
)
|
||||
);
|
||||
|
||||
let sitemap_xml = std::fs::read_to_string(destination.join("sitemap.xml"))?;
|
||||
assert!(sitemap_xml.contains("<loc>https://zed.dev/docs/getting-started.html</loc>"));
|
||||
assert!(sitemap_xml.contains("<loc>https://zed.dev/docs/ai/mcp.html</loc>"));
|
||||
|
||||
let mcp_markdown = std::fs::read_to_string(destination.join("ai/mcp.md"))?;
|
||||
assert!(mcp_markdown.starts_with(
|
||||
"> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt).\n\n# MCP"
|
||||
));
|
||||
assert!(!mcp_markdown.contains("ZED_META"));
|
||||
|
||||
let index_markdown = std::fs::read_to_string(destination.join("index.md"))?;
|
||||
assert!(index_markdown.contains("# Getting Started"));
|
||||
|
||||
std::fs::remove_dir_all(&destination)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
use anyhow::{Context, Result};
|
||||
mod ai_discovery;
|
||||
|
||||
use ai_discovery::{
|
||||
add_markdown_alternate_link, docs_pages, rewrite_docs_links, write_ai_discovery_artifacts,
|
||||
write_markdown_redirect_aliases, write_pages_redirects,
|
||||
};
|
||||
use mdbook::BookItem;
|
||||
use mdbook::book::{Book, Chapter};
|
||||
use mdbook::preprocess::CmdPreprocessor;
|
||||
|
|
@ -188,7 +194,7 @@ fn handle_preprocessing() -> Result<()> {
|
|||
template_big_table_of_actions(&mut book);
|
||||
template_and_validate_keybindings(&mut book, &mut errors);
|
||||
template_and_validate_actions(&mut book, &mut errors);
|
||||
template_and_validate_json_snippets(&mut book, &mut errors);
|
||||
template_and_validate_json_snippets(&mut book, &mut errors)?;
|
||||
|
||||
if !errors.is_empty() {
|
||||
const ANSI_RED: &str = "\x1b[31m";
|
||||
|
|
@ -252,7 +258,7 @@ fn format_binding(binding: String) -> String {
|
|||
}
|
||||
|
||||
fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
|
||||
let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap();
|
||||
let regex = Regex::new(r"(?s)\{#kb(?::(\w+))?\s+(.*?)\}").unwrap();
|
||||
|
||||
for_each_chapter_mut(book, |chapter| {
|
||||
chapter.content = regex
|
||||
|
|
@ -300,7 +306,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Prepr
|
|||
}
|
||||
|
||||
fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
|
||||
let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
|
||||
let regex = Regex::new(r"(?s)\{#action\s+(.*?)\}").unwrap();
|
||||
|
||||
for_each_chapter_mut(book, |chapter| {
|
||||
chapter.content = regex
|
||||
|
|
@ -379,7 +385,10 @@ fn find_binding_with_overlay(
|
|||
.or_else(|| find_binding(os, action))
|
||||
}
|
||||
|
||||
fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
|
||||
fn template_and_validate_json_snippets(
|
||||
book: &mut Book,
|
||||
errors: &mut HashSet<PreprocessorError>,
|
||||
) -> Result<()> {
|
||||
let params = SettingsJsonSchemaParams {
|
||||
language_names: &[],
|
||||
font_names: &[],
|
||||
|
|
@ -393,7 +402,7 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
|
|||
};
|
||||
let settings_schema = SettingsStore::json_schema(¶ms);
|
||||
let settings_validator = jsonschema::validator_for(&settings_schema)
|
||||
.expect("failed to compile settings JSON schema");
|
||||
.context("failed to compile settings JSON schema")?;
|
||||
|
||||
// The keymap schema is built from the action manifest. When `actions.json`
|
||||
// is unavailable (e.g. when running outside of CI without first generating
|
||||
|
|
@ -405,7 +414,7 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
|
|||
keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
|
||||
Some(
|
||||
jsonschema::validator_for(&keymap_schema)
|
||||
.expect("failed to compile keymap JSON schema"),
|
||||
.context("failed to compile keymap JSON schema")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
|
|
@ -568,6 +577,8 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
|
|||
};
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes any configurable options from the stringified action if existing,
|
||||
|
|
@ -679,6 +690,19 @@ fn handle_postprocessing() -> Result<()> {
|
|||
.as_table_mut()
|
||||
.expect("output is table");
|
||||
let zed_html = output.remove("zed-html").expect("zed-html output defined");
|
||||
let redirects = zed_html
|
||||
.get("redirect")
|
||||
.and_then(|redirects| redirects.as_table())
|
||||
.map(|redirects| {
|
||||
redirects
|
||||
.iter()
|
||||
.filter_map(|(source, destination)| {
|
||||
destination
|
||||
.as_str()
|
||||
.map(|destination| (source.clone(), destination.to_string()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let default_description = zed_html
|
||||
.get("default-description")
|
||||
.expect("Default description not found")
|
||||
|
|
@ -694,6 +718,17 @@ fn handle_postprocessing() -> Result<()> {
|
|||
let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
|
||||
let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
|
||||
let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
|
||||
let site_url = std::env::var("MDBOOK_BOOK__SITE_URL")
|
||||
.ok()
|
||||
.filter(|site_url| !site_url.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
match docs_channel.as_str() {
|
||||
"nightly" => "/docs/nightly/",
|
||||
"preview" => "/docs/preview/",
|
||||
_ => "/docs/",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
|
||||
"<meta name=\"robots\" content=\"noindex, nofollow\">"
|
||||
} else {
|
||||
|
|
@ -733,8 +768,10 @@ fn handle_postprocessing() -> Result<()> {
|
|||
}
|
||||
|
||||
zlog::info!(logger => "Processing {} `.html` files", files.len());
|
||||
let pages = docs_pages(&ctx.book)?;
|
||||
write_ai_discovery_artifacts(&pages, &root_dir, &site_url)?;
|
||||
let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
|
||||
for file in files {
|
||||
for file in &files {
|
||||
let contents = std::fs::read_to_string(&file)?;
|
||||
let mut meta_description = None;
|
||||
let mut meta_title = None;
|
||||
|
|
@ -770,14 +807,19 @@ fn handle_postprocessing() -> Result<()> {
|
|||
let contents = contents.replace("#amplitude_key#", &litude_key);
|
||||
let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
|
||||
let contents = contents.replace("#noindex#", noindex);
|
||||
let contents = rewrite_docs_links(&contents, &site_url);
|
||||
let contents = add_markdown_alternate_link(&contents, file, &root_dir, &site_url);
|
||||
let contents = title_regex()
|
||||
.replace(&contents, |_: ®ex::Captures| {
|
||||
format!("<title>{}</title>", meta_title)
|
||||
})
|
||||
.to_string();
|
||||
// let contents = contents.replace("#title#", &meta_title);
|
||||
std::fs::write(file, contents)?;
|
||||
}
|
||||
if let Some(redirects) = redirects {
|
||||
write_markdown_redirect_aliases(&root_dir, &redirects, &site_url)?;
|
||||
write_pages_redirects(&root_dir, &redirects, &site_url)?;
|
||||
}
|
||||
return Ok(());
|
||||
|
||||
fn pretty_path<'a>(
|
||||
|
|
@ -906,66 +948,4 @@ fn keymap_schema_for_actions(
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_prefers_exact_match_over_parameterized() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{
|
||||
"bindings": {
|
||||
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
|
||||
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_falls_back_to_parameterized_match() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{
|
||||
"bindings": {
|
||||
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_prefers_exact_match_regardless_of_order() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{
|
||||
"bindings": {
|
||||
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
|
||||
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_later_section_overrides_earlier() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{ "bindings": { "ctrl-a": "some::Action" } },
|
||||
{ "bindings": { "ctrl-b": "some::Action" } }
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "some::Action");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-b"));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
|
|
|||
61
crates/docs_preprocessor/src/tests.rs
Normal file
61
crates/docs_preprocessor/src/tests.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_prefers_exact_match_over_parameterized() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{
|
||||
"bindings": {
|
||||
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
|
||||
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_falls_back_to_parameterized_match() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{
|
||||
"bindings": {
|
||||
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_prefers_exact_match_regardless_of_order() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{
|
||||
"bindings": {
|
||||
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
|
||||
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_binding_later_section_overrides_earlier() {
|
||||
let keymap: KeymapFile = serde_json::from_value(json!([
|
||||
{ "bindings": { "ctrl-a": "some::Action" } },
|
||||
{ "bindings": { "ctrl-b": "some::Action" } }
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
let binding = find_binding_in_keymap(&keymap, "some::Action");
|
||||
assert_eq!(binding.as_deref(), Some("ctrl-b"));
|
||||
}
|
||||
|
|
@ -104,6 +104,34 @@ impl EditPredictionResult {
|
|||
e2e_latency,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_rejected(
|
||||
id: EditPredictionId,
|
||||
edited_buffer: &Entity<Buffer>,
|
||||
edited_buffer_snapshot: &BufferSnapshot,
|
||||
inputs: EditPredictionInputs,
|
||||
model_version: Option<String>,
|
||||
trigger: PredictEditsRequestTrigger,
|
||||
e2e_latency: std::time::Duration,
|
||||
reject_reason: EditPredictionRejectReason,
|
||||
) -> Self {
|
||||
Self {
|
||||
prediction: EditPrediction {
|
||||
id,
|
||||
edits: Arc::default(),
|
||||
cursor_position: None,
|
||||
editable_range: None,
|
||||
snapshot: edited_buffer_snapshot.clone(),
|
||||
edit_preview: EditPreview::unchanged(edited_buffer_snapshot),
|
||||
inputs,
|
||||
buffer: edited_buffer.clone(),
|
||||
model_version,
|
||||
trigger,
|
||||
},
|
||||
reject_reason: Some(reject_reason),
|
||||
e2e_latency,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ use crate::{
|
|||
udiff::prediction_edits_for_single_file_diff,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use cloud_llm_client::{AcceptEditPredictionBody, predict_edits_v3::RawCompletionRequest};
|
||||
use cloud_llm_client::{
|
||||
AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
|
||||
};
|
||||
use edit_prediction_types::PredictedCursorPosition;
|
||||
use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*};
|
||||
use language::{
|
||||
|
|
@ -516,26 +518,41 @@ pub(crate) fn request_prediction_with_zeta(
|
|||
snapshot: fallback_snapshot,
|
||||
patch,
|
||||
} => {
|
||||
let Some((buffer, snapshot, edits, cursor_position)) =
|
||||
prediction_edits_for_single_file_diff(&patch, &project, cx).await?
|
||||
else {
|
||||
return Ok(Some(
|
||||
EditPredictionResult::new(
|
||||
id,
|
||||
&fallback_buffer,
|
||||
&fallback_snapshot,
|
||||
Arc::new([]),
|
||||
None,
|
||||
None,
|
||||
inputs,
|
||||
model_version,
|
||||
trigger,
|
||||
request_duration,
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
));
|
||||
};
|
||||
let (buffer, snapshot, edits, cursor_position) =
|
||||
match prediction_edits_for_single_file_diff(&patch, &project, cx).await {
|
||||
Ok(Some(edits)) => edits,
|
||||
Ok(None) => {
|
||||
return Ok(Some(
|
||||
EditPredictionResult::new(
|
||||
id,
|
||||
&fallback_buffer,
|
||||
&fallback_snapshot,
|
||||
Arc::new([]),
|
||||
None,
|
||||
None,
|
||||
inputs,
|
||||
model_version,
|
||||
trigger,
|
||||
request_duration,
|
||||
cx,
|
||||
)
|
||||
.await,
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("failed to apply edit prediction patch: {error:?}");
|
||||
return Ok(Some(EditPredictionResult::new_rejected(
|
||||
id,
|
||||
&fallback_buffer,
|
||||
&fallback_snapshot,
|
||||
inputs,
|
||||
model_version,
|
||||
trigger,
|
||||
request_duration,
|
||||
EditPredictionRejectReason::PatchApplyFailed,
|
||||
)));
|
||||
}
|
||||
};
|
||||
let editable_range_in_buffer =
|
||||
edits
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -669,10 +669,14 @@ actions!(
|
|||
MoveToEnd,
|
||||
/// Moves cursor to the end of the paragraph.
|
||||
MoveToEndOfParagraph,
|
||||
/// Moves cursor to the start of the next comment paragraph.
|
||||
MoveToNextCommentParagraph,
|
||||
/// Moves cursor to the end of the next subword.
|
||||
MoveToNextSubwordEnd,
|
||||
/// Moves cursor to the end of the next word.
|
||||
MoveToNextWordEnd,
|
||||
/// Moves cursor to the start of the previous comment paragraph.
|
||||
MoveToPreviousCommentParagraph,
|
||||
/// Moves cursor to the start of the previous subword.
|
||||
MoveToPreviousSubwordStart,
|
||||
/// Moves cursor to the start of the previous word.
|
||||
|
|
|
|||
|
|
@ -963,9 +963,14 @@ impl BlockMap {
|
|||
// Ensure the edit starts at a transform boundary.
|
||||
// If the edit starts within an isomorphic transform, preserve its prefix
|
||||
// If the edit lands within a replacement block, expand the edit to include the start of the replaced input range
|
||||
let transform = cursor.item().unwrap();
|
||||
// The cursor can sit past the end of the tree when a companion edit
|
||||
// is anchored at the new end-of-file and maps to `old_start` at the
|
||||
// very end of the old transforms; in that case there is no transform
|
||||
// preceding the edit and nothing to preserve.
|
||||
let transform_rows_before_edit = old_start - *cursor.start();
|
||||
if transform_rows_before_edit > RowDelta(0) {
|
||||
if transform_rows_before_edit > RowDelta(0)
|
||||
&& let Some(transform) = cursor.item()
|
||||
{
|
||||
if transform.block.is_none() {
|
||||
// Preserve any portion of the old isomorphic transform that precedes this edit.
|
||||
push_isomorphic(
|
||||
|
|
@ -5019,6 +5024,61 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
// Regression test for ZED-9V4: `BlockMap::sync` walks the (old) transform
|
||||
// tree with a `WrapRow` cursor and used to `cursor.item().unwrap()` for
|
||||
// every edit, assuming each edit's `old.start` lands strictly inside the
|
||||
// tree. The companion (split-diff) branch of `sync` can compose an edit
|
||||
// anchored at the trailing boundary of the old transforms
|
||||
// (`old.start == input_rows`), at which point the cursor is past the end of
|
||||
// the tree and `item()` is `None`. That used to abort the process.
|
||||
#[gpui::test]
|
||||
fn test_sync_edit_anchored_at_end_of_transforms(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(init_test);
|
||||
|
||||
let buffer = cx.update(|cx| MultiBuffer::build_simple("aaa\nbbb\nccc\n", cx));
|
||||
let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx));
|
||||
let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
|
||||
let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
|
||||
let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
|
||||
let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
|
||||
let (wrap_map, old_wrap_snapshot) =
|
||||
cx.update(|cx| WrapMap::new(tab_snapshot, test_font(), px(14.0), None, cx));
|
||||
let block_map = BlockMap::new(old_wrap_snapshot.clone(), 0, 0);
|
||||
|
||||
// The tree now spans exactly `old_end` input rows.
|
||||
let old_end = old_wrap_snapshot.max_point().row() + RowDelta(1);
|
||||
|
||||
// Grow the buffer so the new snapshot is larger than the old transforms.
|
||||
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(
|
||||
[(Point::new(3, 0)..Point::new(3, 0), "ddd\neee\n")],
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
buffer.snapshot(cx)
|
||||
});
|
||||
let (inlay_snapshot, inlay_edits) =
|
||||
inlay_map.sync(buffer_snapshot, subscription.consume().into_inner());
|
||||
let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
|
||||
let (tab_snapshot, tab_edits) =
|
||||
tab_map.sync(fold_snapshot, fold_edits, 4.try_into().unwrap());
|
||||
let (new_wrap_snapshot, _) = wrap_map.update(cx, |wrap_map, cx| {
|
||||
wrap_map.sync(tab_snapshot, tab_edits, cx)
|
||||
});
|
||||
let new_end = new_wrap_snapshot.max_point().row() + RowDelta(1);
|
||||
|
||||
// An edit anchored exactly at the end of the old transforms, of the shape
|
||||
// the companion branch of `sync` can produce.
|
||||
let edits = Patch::new(vec![text::Edit {
|
||||
old: old_end..old_end,
|
||||
new: old_end..new_end,
|
||||
}]);
|
||||
|
||||
let snapshot = block_map.read(new_wrap_snapshot, edits, None);
|
||||
assert_eq!(snapshot.snapshot.text(), "aaa\nbbb\nccc\nddd\neee\n");
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut gpui::App) {
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
|
|
|
|||
|
|
@ -9568,6 +9568,9 @@ impl Editor {
|
|||
ranges,
|
||||
path_key,
|
||||
} => {
|
||||
if let Some(hovered_link_state) = self.hovered_link_state.as_mut() {
|
||||
hovered_link_state.symbol_range = None;
|
||||
}
|
||||
self.refresh_document_highlights(cx);
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
if self.buffer.read(cx).diff_for(buffer_id).is_none()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -287,22 +287,22 @@ impl EditorElement {
|
|||
register_action(editor, window, Editor::scroll_cursor_bottom);
|
||||
register_action(editor, window, Editor::scroll_cursor_center_top_bottom);
|
||||
register_action(editor, window, |editor, _: &LineDown, window, cx| {
|
||||
editor.scroll_screen(&ScrollAmount::Line(1.), window, cx)
|
||||
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx)
|
||||
});
|
||||
register_action(editor, window, |editor, _: &LineUp, window, cx| {
|
||||
editor.scroll_screen(&ScrollAmount::Line(-1.), window, cx)
|
||||
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx)
|
||||
});
|
||||
register_action(editor, window, |editor, _: &HalfPageDown, window, cx| {
|
||||
editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx)
|
||||
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx)
|
||||
});
|
||||
register_action(editor, window, |editor, _: &HalfPageUp, window, cx| {
|
||||
editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx)
|
||||
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx)
|
||||
});
|
||||
register_action(editor, window, |editor, _: &PageDown, window, cx| {
|
||||
editor.scroll_screen(&ScrollAmount::Page(1.), window, cx)
|
||||
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(1.), window, cx)
|
||||
});
|
||||
register_action(editor, window, |editor, _: &PageUp, window, cx| {
|
||||
editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx)
|
||||
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-1.), window, cx)
|
||||
});
|
||||
register_action(editor, window, Editor::move_to_previous_word_start);
|
||||
register_action(editor, window, Editor::move_to_previous_subword_start);
|
||||
|
|
@ -312,6 +312,8 @@ impl EditorElement {
|
|||
register_action(editor, window, Editor::move_to_end_of_line);
|
||||
register_action(editor, window, Editor::move_to_start_of_paragraph);
|
||||
register_action(editor, window, Editor::move_to_end_of_paragraph);
|
||||
register_action(editor, window, Editor::move_to_next_comment_paragraph);
|
||||
register_action(editor, window, Editor::move_to_previous_comment_paragraph);
|
||||
register_action(editor, window, Editor::move_to_beginning);
|
||||
register_action(editor, window, Editor::move_to_end);
|
||||
register_action(editor, window, Editor::move_to_start_of_excerpt);
|
||||
|
|
|
|||
|
|
@ -1050,11 +1050,7 @@ impl Editor {
|
|||
);
|
||||
}
|
||||
|
||||
pub(super) fn restore_diff_hunks(
|
||||
&mut self,
|
||||
hunks: Vec<ResolvedDiffHunks>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
pub fn restore_diff_hunks(&mut self, hunks: Vec<ResolvedDiffHunks>, cx: &mut Context<Self>) {
|
||||
let mut revert_changes = Vec::new();
|
||||
for hunks in hunks {
|
||||
let Some(buffer) = hunks.buffer else {
|
||||
|
|
@ -2110,6 +2106,17 @@ impl Editor {
|
|||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub fn restore_diff_hunks_in_ranges(
|
||||
&mut self,
|
||||
ranges: Vec<Range<Anchor>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let snapshot = self.buffer.read(cx).snapshot(cx);
|
||||
let hunks = self.diff_hunks_in_ranges(&ranges, &snapshot).collect();
|
||||
self.apply_restore(hunks, window, cx);
|
||||
}
|
||||
|
||||
fn toggle_diff_hunks_in_ranges(
|
||||
&mut self,
|
||||
ranges: Vec<Range<Anchor>>,
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ use gpui::{
|
|||
};
|
||||
use language::{
|
||||
Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT,
|
||||
Point, SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
|
||||
Point, SelectionGoal,
|
||||
language_settings::{FormatOnSave, LanguageSettings},
|
||||
proto::serialize_anchor as serialize_text_anchor,
|
||||
};
|
||||
use lsp::DiagnosticSeverity;
|
||||
use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey};
|
||||
use project::{
|
||||
File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger,
|
||||
File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger,
|
||||
project_settings::ProjectSettings, search::SearchQuery,
|
||||
};
|
||||
use rope::TextSummary;
|
||||
|
|
@ -39,7 +41,7 @@ use std::{
|
|||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection};
|
||||
use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _};
|
||||
use ui::{IconDecorationKind, prelude::*};
|
||||
use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath};
|
||||
use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
|
||||
|
|
@ -974,16 +976,21 @@ impl Item for Editor {
|
|||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if options.format {
|
||||
this.update_in(cx, |editor, window, cx| {
|
||||
editor.perform_format(
|
||||
project.clone(),
|
||||
let format_task = this.update_in(cx, |editor, window, cx| {
|
||||
let format_target = compute_format_target(
|
||||
&buffers_to_save,
|
||||
format_trigger,
|
||||
FormatTarget::Buffers(buffers_to_save.clone()),
|
||||
window,
|
||||
editor.buffer(),
|
||||
project.read(cx).git_store(),
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
);
|
||||
format_target.map(|target| {
|
||||
editor.perform_format(project.clone(), format_trigger, target, window, cx)
|
||||
})
|
||||
})?;
|
||||
if let Some(format_task) = format_task {
|
||||
format_task.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if !buffers_to_save.is_empty() {
|
||||
|
|
@ -2267,6 +2274,115 @@ fn chunk_search_range(
|
|||
}))
|
||||
}
|
||||
|
||||
/// Decides what to format based on the `format_on_save` settings of the saved buffers.
|
||||
///
|
||||
/// In the modifications modes, only lines with unstaged changes are formatted.
|
||||
/// When no git diff is available for a buffer, `modifications` skips formatting while `modifications_if_available`
|
||||
/// falls back to formatting entire buffers.
|
||||
/// When a diff is available but empty, nothing is formatted in either mode.
|
||||
fn compute_format_target(
|
||||
buffers: &HashSet<Entity<Buffer>>,
|
||||
trigger: FormatTrigger,
|
||||
multi_buffer: &Entity<MultiBuffer>,
|
||||
git_store: &Entity<GitStore>,
|
||||
cx: &App,
|
||||
) -> Option<FormatTarget> {
|
||||
if trigger == FormatTrigger::Manual {
|
||||
return Some(FormatTarget::Buffers(buffers.clone()));
|
||||
}
|
||||
|
||||
let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
|
||||
let git_store = git_store.read(cx);
|
||||
|
||||
let mut fall_back_to_full_format = false;
|
||||
let mut modified_ranges: Vec<Range<Point>> = Vec::new();
|
||||
|
||||
for buffer_entity in buffers.iter() {
|
||||
let buffer = buffer_entity.read(cx);
|
||||
let settings = LanguageSettings::for_buffer(buffer, cx);
|
||||
match settings.format_on_save {
|
||||
FormatOnSave::On | FormatOnSave::Off => {
|
||||
return Some(FormatTarget::Buffers(buffers.clone()));
|
||||
}
|
||||
FormatOnSave::Modifications | FormatOnSave::ModificationsIfAvailable => {}
|
||||
}
|
||||
|
||||
let Some(diff_snapshot) = git_store
|
||||
.get_unstaged_diff(buffer.remote_id(), cx)
|
||||
.map(|diff| diff.read(cx).snapshot(cx))
|
||||
else {
|
||||
if settings.format_on_save == FormatOnSave::ModificationsIfAvailable {
|
||||
fall_back_to_full_format = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
let anchor_ranges = compute_modified_ranges(&buffer.snapshot(), &diff_snapshot);
|
||||
let flat_anchors = anchor_ranges
|
||||
.iter()
|
||||
.flat_map(|range| [range.start, range.end])
|
||||
.collect::<Vec<_>>();
|
||||
let multi_buffer_anchors =
|
||||
multi_buffer_snapshot.text_anchors_to_visible_anchors(flat_anchors);
|
||||
for pair in multi_buffer_anchors.chunks_exact(2) {
|
||||
let (Some(start), Some(end)) = (&pair[0], &pair[1]) else {
|
||||
continue;
|
||||
};
|
||||
modified_ranges
|
||||
.push(start.to_point(&multi_buffer_snapshot)..end.to_point(&multi_buffer_snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
if fall_back_to_full_format {
|
||||
Some(FormatTarget::Buffers(buffers.clone()))
|
||||
} else if modified_ranges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(FormatTarget::Ranges(modified_ranges))
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the buffer ranges that have unstaged changes, expanded to full lines and
|
||||
/// with adjacent hunks merged, for use with format-on-save. An empty result means the
|
||||
/// buffer has no formatable modifications.
|
||||
fn compute_modified_ranges(
|
||||
buffer_snapshot: &language::BufferSnapshot,
|
||||
diff_snapshot: &buffer_diff::BufferDiffSnapshot,
|
||||
) -> Vec<Range<text::Anchor>> {
|
||||
let mut merged: Vec<Range<text::Anchor>> = Vec::new();
|
||||
for hunk in diff_snapshot.hunks(buffer_snapshot) {
|
||||
let range = hunk.buffer_range;
|
||||
if range.start.cmp(&range.end, buffer_snapshot).is_eq() {
|
||||
// Deletion-only hunks produce no buffer content to format.
|
||||
continue;
|
||||
}
|
||||
let start_point = range.start.to_point(buffer_snapshot);
|
||||
let end_point = range.end.to_point(buffer_snapshot);
|
||||
let start_row = start_point.row;
|
||||
let end_row = if end_point.column == 0 && end_point.row > start_point.row {
|
||||
end_point.row - 1
|
||||
} else {
|
||||
end_point.row
|
||||
};
|
||||
let line_start = text::Point::new(start_row, 0);
|
||||
let line_end = text::Point::new(end_row, buffer_snapshot.line_len(end_row));
|
||||
let expanded =
|
||||
buffer_snapshot.anchor_before(line_start)..buffer_snapshot.anchor_after(line_end);
|
||||
|
||||
if let Some(last) = merged.last_mut() {
|
||||
let last_end_point = last.end.to_point(buffer_snapshot);
|
||||
if start_row <= last_end_point.row + 1 {
|
||||
if expanded.end.to_point(buffer_snapshot) > last_end_point {
|
||||
last.end = expanded.end;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.push(expanded);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::editor_tests::init_test;
|
||||
|
|
@ -2921,4 +3037,124 @@ mod tests {
|
|||
"Editor::deserialize should not add items to panes as a side effect"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_compute_modified_ranges_git_diff(cx: &mut gpui::TestAppContext) {
|
||||
let base_text = "line0\nline1\nline2\nline3\nline4\nline5\nline6\n";
|
||||
// Modify line1 and line5 to create two non-adjacent hunks.
|
||||
let buffer_text = "line0\nMOD1\nline2\nline3\nline4\nMOD5\nline6\n";
|
||||
|
||||
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
|
||||
let diff_snapshot = buffer.update(cx, |buffer, cx| {
|
||||
let diff = cx.new(|cx| {
|
||||
buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
|
||||
});
|
||||
diff.read(cx).snapshot(cx)
|
||||
});
|
||||
|
||||
let ranges = buffer.update(cx, |buffer, _cx| {
|
||||
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
|
||||
});
|
||||
|
||||
assert_eq!(ranges.len(), 2, "expected 2 non-adjacent ranges");
|
||||
|
||||
buffer.update(cx, |buffer, _cx| {
|
||||
let text_snapshot: &text::BufferSnapshot = buffer;
|
||||
let r0 = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
|
||||
let r1 = ranges[1].start.to_point(text_snapshot)..ranges[1].end.to_point(text_snapshot);
|
||||
assert_eq!(r0.start.row, 1, "first hunk should start at row 1");
|
||||
assert_eq!(r0.end.row, 1, "first hunk should end at row 1");
|
||||
assert_eq!(r1.start.row, 5, "second hunk should start at row 5");
|
||||
assert_eq!(r1.end.row, 5, "second hunk should end at row 5");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_compute_modified_ranges_unchanged_buffer(cx: &mut gpui::TestAppContext) {
|
||||
let buffer_text = "line0\nline1\nline2\n";
|
||||
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
|
||||
let diff_snapshot = buffer.update(cx, |buffer, cx| {
|
||||
let diff = cx.new(|cx| {
|
||||
buffer_diff::BufferDiff::new_with_base_text(
|
||||
buffer_text,
|
||||
&buffer.text_snapshot(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
diff.read(cx).snapshot(cx)
|
||||
});
|
||||
|
||||
let ranges = buffer.update(cx, |buffer, _cx| {
|
||||
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ranges,
|
||||
Vec::new(),
|
||||
"buffer that matches its diff base should produce no modified ranges"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_compute_modified_ranges_deletion_only(cx: &mut gpui::TestAppContext) {
|
||||
let base_text = "line0\nline1\nline2\n";
|
||||
// Buffer has line1 deleted (pure deletion).
|
||||
let buffer_text = "line0\nline2\n";
|
||||
|
||||
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
|
||||
let diff_snapshot = buffer.update(cx, |buffer, cx| {
|
||||
let diff = cx.new(|cx| {
|
||||
buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
|
||||
});
|
||||
diff.read(cx).snapshot(cx)
|
||||
});
|
||||
|
||||
// Verify the diff has a deletion hunk.
|
||||
let hunk_count = buffer.update(cx, |buffer, _cx| {
|
||||
let text_snapshot: &text::BufferSnapshot = buffer;
|
||||
diff_snapshot.hunks(text_snapshot).count()
|
||||
});
|
||||
assert!(hunk_count > 0, "diff should have hunks");
|
||||
|
||||
let ranges = buffer.update(cx, |buffer, _cx| {
|
||||
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ranges,
|
||||
Vec::new(),
|
||||
"deletion-only hunks should be skipped, leaving no ranges"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_compute_modified_ranges_adjacent_hunks(cx: &mut gpui::TestAppContext) {
|
||||
let base_text = "line0\nline1\nline2\nline3\nline4\n";
|
||||
// Modify lines 2 and 3 which are adjacent; they should merge into one range.
|
||||
let buffer_text = "line0\nline1\nMOD2\nMOD3\nline4\n";
|
||||
|
||||
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
|
||||
let diff_snapshot = buffer.update(cx, |buffer, cx| {
|
||||
let diff = cx.new(|cx| {
|
||||
buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
|
||||
});
|
||||
diff.read(cx).snapshot(cx)
|
||||
});
|
||||
|
||||
let ranges = buffer.update(cx, |buffer, _cx| {
|
||||
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ranges.len(),
|
||||
1,
|
||||
"adjacent hunks (rows 2 and 3) should be merged into one range"
|
||||
);
|
||||
buffer.update(cx, |buffer, _cx| {
|
||||
let text_snapshot: &text::BufferSnapshot = buffer;
|
||||
let r = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
|
||||
assert_eq!(r.start.row, 2, "merged range should start at row 2");
|
||||
assert_eq!(r.end.row, 3, "merged range should end at row 3");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -582,6 +582,99 @@ pub fn end_of_paragraph(
|
|||
map.max_point()
|
||||
}
|
||||
|
||||
/// Returns whether `row` is part of a comment paragraph: a line whose first
|
||||
/// non-whitespace character lies within a comment scope and which contains at
|
||||
/// least one alphanumeric character.
|
||||
///
|
||||
/// This intentionally excludes:
|
||||
/// - blank lines and code lines,
|
||||
/// - end-of-line comments preceded by code (the first non-whitespace character
|
||||
/// is then code, not a comment),
|
||||
/// - "blank"/divider comment lines such as a bare `//` or `// -----` (no
|
||||
/// alphanumeric content), which act as paragraph separators.
|
||||
fn is_comment_paragraph_line(snapshot: &MultiBufferSnapshot, row: u32) -> bool {
|
||||
let buffer_row = MultiBufferRow(row);
|
||||
if snapshot.is_line_blank(buffer_row) {
|
||||
return false;
|
||||
}
|
||||
let indent_len = snapshot.indent_size_for_line(buffer_row).len;
|
||||
let indent_end = Point::new(row, indent_len);
|
||||
let in_comment = snapshot.language_scope_at(indent_end).is_some_and(|scope| {
|
||||
matches!(
|
||||
scope.override_name(),
|
||||
Some("comment") | Some("comment.inclusive")
|
||||
)
|
||||
});
|
||||
if !in_comment {
|
||||
return false;
|
||||
}
|
||||
let line_end = Point::new(row, snapshot.line_len(buffer_row));
|
||||
snapshot
|
||||
.text_for_range(indent_end..line_end)
|
||||
.flat_map(|chunk| chunk.chars())
|
||||
.any(|c| c.is_alphanumeric())
|
||||
}
|
||||
|
||||
/// Returns the position of the first non-whitespace character of the next or
|
||||
/// previous comment paragraph, relative to `from`.
|
||||
///
|
||||
/// A comment paragraph is a run of consecutive comment lines (see
|
||||
/// [`is_comment_paragraph_line`]); paragraphs are separated by blank lines, code
|
||||
/// lines, and blank/divider comment lines. If no such paragraph exists in the
|
||||
/// requested direction, `from` is returned unchanged.
|
||||
///
|
||||
/// Both directions always move to a *different* paragraph than the one the
|
||||
/// caret is in: when the caret is inside a comment paragraph, the entire
|
||||
/// current paragraph is skipped, so `Prev` lands on the previous paragraph's
|
||||
/// start rather than the current paragraph's own start.
|
||||
pub fn comment_paragraph(
|
||||
map: &DisplaySnapshot,
|
||||
from: DisplayPoint,
|
||||
direction: Direction,
|
||||
) -> DisplayPoint {
|
||||
let snapshot = map.buffer_snapshot();
|
||||
let from_point = from.to_point(map);
|
||||
let max_row = snapshot.max_row().0;
|
||||
|
||||
let is_paragraph_start = |row: u32| {
|
||||
is_comment_paragraph_line(snapshot, row)
|
||||
&& (row == 0 || !is_comment_paragraph_line(snapshot, row - 1))
|
||||
};
|
||||
let paragraph_start_point =
|
||||
|row: u32| Point::new(row, snapshot.indent_size_for_line(MultiBufferRow(row)).len);
|
||||
|
||||
let target = match direction {
|
||||
Direction::Next => (from_point.row..=max_row).find_map(|row| {
|
||||
let point = paragraph_start_point(row);
|
||||
(point > from_point && is_paragraph_start(row)).then_some(point)
|
||||
}),
|
||||
Direction::Prev => {
|
||||
// If the caret is within a comment paragraph, skip over the whole
|
||||
// current paragraph so we land on the *previous* paragraph rather
|
||||
// than stopping at the current paragraph's own start.
|
||||
let mut boundary_row = from_point.row;
|
||||
if is_comment_paragraph_line(snapshot, boundary_row) {
|
||||
while boundary_row > 0 && is_comment_paragraph_line(snapshot, boundary_row - 1) {
|
||||
boundary_row -= 1;
|
||||
}
|
||||
(0..boundary_row)
|
||||
.rev()
|
||||
.find_map(|row| is_paragraph_start(row).then(|| paragraph_start_point(row)))
|
||||
} else {
|
||||
(0..=from_point.row).rev().find_map(|row| {
|
||||
let point = paragraph_start_point(row);
|
||||
(point < from_point && is_paragraph_start(row)).then_some(point)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match target {
|
||||
Some(point) => map.clip_point(point.to_display_point(map), Bias::Right),
|
||||
None => from,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_of_excerpt(
|
||||
map: &DisplaySnapshot,
|
||||
display_point: DisplayPoint,
|
||||
|
|
|
|||
|
|
@ -608,6 +608,68 @@ impl Editor {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn move_to_next_comment_paragraph(
|
||||
&mut self,
|
||||
_: &MoveToNextCommentParagraph,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if matches!(self.mode, EditorMode::SingleLine) {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
// Keep the destination paragraph near the top of the viewport so the
|
||||
// whole paragraph below the caret stays visible after a jump.
|
||||
self.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.move_with(&mut |map, selection| {
|
||||
selection.collapse_to(
|
||||
movement::comment_paragraph(
|
||||
map,
|
||||
selection.head(),
|
||||
workspace::searchable::Direction::Next,
|
||||
),
|
||||
SelectionGoal::None,
|
||||
)
|
||||
});
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn move_to_previous_comment_paragraph(
|
||||
&mut self,
|
||||
_: &MoveToPreviousCommentParagraph,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if matches!(self.mode, EditorMode::SingleLine) {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
// Keep the destination paragraph near the top of the viewport so the
|
||||
// whole paragraph below the caret stays visible after a jump.
|
||||
self.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.move_with(&mut |map, selection| {
|
||||
selection.collapse_to(
|
||||
movement::comment_paragraph(
|
||||
map,
|
||||
selection.head(),
|
||||
workspace::searchable::Direction::Prev,
|
||||
),
|
||||
SelectionGoal::None,
|
||||
)
|
||||
});
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn select_to_start_of_paragraph(
|
||||
&mut self,
|
||||
_: &SelectToStartOfParagraph,
|
||||
|
|
|
|||
|
|
@ -537,6 +537,7 @@ impl Editor {
|
|||
let quick_launch = match e {
|
||||
ClickEvent::Keyboard(_) => true,
|
||||
ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
|
||||
ClickEvent::Touch(_) => true,
|
||||
};
|
||||
|
||||
window.focus(&editor.focus_handle(cx), cx);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ pub(crate) mod scroll_amount;
|
|||
use crate::editor_settings::ScrollBeyondLastLine;
|
||||
use crate::{
|
||||
Anchor, DisplayPoint, DisplayRow, Editor, EditorEvent, EditorMode, EditorSettings,
|
||||
MultiBufferSnapshot, RowExt, SizingBehavior, ToPoint,
|
||||
MultiBufferSnapshot, RowExt, SelectionEffects, SizingBehavior, ToPoint,
|
||||
display_map::{DisplaySnapshot, ToDisplayPoint},
|
||||
hover_popover::hide_hover,
|
||||
persistence::EditorDb,
|
||||
|
|
@ -945,6 +945,63 @@ impl Editor {
|
|||
self.set_scroll_position(new_position, window, cx);
|
||||
}
|
||||
|
||||
pub fn scroll_screen_with_cursor_margin(
|
||||
&mut self,
|
||||
amount: &ScrollAmount,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.scroll_screen(amount, window, cx);
|
||||
|
||||
let Some(visible_line_count) = self.visible_line_count() else {
|
||||
return;
|
||||
};
|
||||
let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
|
||||
let top = self
|
||||
.scroll_manager
|
||||
.scroll_top_display_point(&display_snapshot, cx);
|
||||
let vertical_scroll_margin =
|
||||
(self.vertical_scroll_margin() as u32).min(visible_line_count as u32 / 2);
|
||||
|
||||
let max_point = display_snapshot.max_point();
|
||||
let min_row = if top.row().0 == 0 {
|
||||
DisplayRow(0)
|
||||
} else {
|
||||
DisplayRow(top.row().0 + vertical_scroll_margin)
|
||||
};
|
||||
let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
|
||||
max_point.row()
|
||||
} else {
|
||||
DisplayRow(
|
||||
(top.row().0 + visible_line_count as u32)
|
||||
.saturating_sub(1 + vertical_scroll_margin),
|
||||
)
|
||||
};
|
||||
|
||||
self.change_selections(
|
||||
SelectionEffects::no_scroll().nav_history(false),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.move_with(&mut |map, selection| {
|
||||
let head = selection.head();
|
||||
let new_row = if head.row() < min_row {
|
||||
min_row
|
||||
} else if head.row() > max_row {
|
||||
max_row
|
||||
} else {
|
||||
head.row()
|
||||
};
|
||||
if new_row != head.row() {
|
||||
let new_head =
|
||||
map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left);
|
||||
selection.collapse_to(new_head, selection.goal);
|
||||
}
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns an ordering. The newest selection is:
|
||||
/// Ordering::Equal => on screen
|
||||
/// Ordering::Less => above or to the left of the screen
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use std::{collections::HashMap, num::NonZeroU32};
|
|||
pub struct LanguageSettings {
|
||||
/// How many columns a tab should occupy.
|
||||
pub tab_size: NonZeroU32,
|
||||
/// Whether to indent with hard tabs (true) or spaces (false).
|
||||
pub hard_tabs: bool,
|
||||
/// The preferred line length (column at which to wrap).
|
||||
pub preferred_line_length: u32,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -968,6 +968,7 @@ impl ExtensionImports for WasmState {
|
|||
);
|
||||
Ok(serde_json::to_string(&settings::LanguageSettings {
|
||||
tab_size: settings.tab_size,
|
||||
hard_tabs: settings.hard_tabs,
|
||||
preferred_line_length: settings.preferred_line_length,
|
||||
})?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,14 +118,3 @@ impl FeatureFlag for AutoWatchFeatureFlag {
|
|||
type Value = PresenceFlag;
|
||||
}
|
||||
register_feature_flag!(AutoWatchFeatureFlag);
|
||||
|
||||
/// Wraps agent-run terminal commands in an OS-level sandbox where supported
|
||||
/// (currently macOS Seatbelt only). When off, terminal commands run with the
|
||||
/// agent's full ambient permissions, as they always have.
|
||||
pub struct SandboxingFeatureFlag;
|
||||
|
||||
impl FeatureFlag for SandboxingFeatureFlag {
|
||||
const NAME: &'static str = "sandboxing";
|
||||
type Value = PresenceFlag;
|
||||
}
|
||||
register_feature_flag!(SandboxingFeatureFlag);
|
||||
|
|
|
|||
|
|
@ -262,7 +262,18 @@ impl GitRepository for FakeGitRepository {
|
|||
|
||||
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
|
||||
self.with_state_async(false, move |state| {
|
||||
let sha = state.refs.get(&commit).cloned().unwrap_or(commit);
|
||||
let sha = match state.refs.get(&commit) {
|
||||
Some(sha) => sha.clone(),
|
||||
// Real git fails to show an unresolvable revision (e.g. HEAD on an
|
||||
// unborn branch), so only fall back to treating the input as a sha.
|
||||
None => {
|
||||
anyhow::ensure!(
|
||||
commit.parse::<Oid>().is_ok(),
|
||||
"unable to resolve revision: {commit}"
|
||||
);
|
||||
commit
|
||||
}
|
||||
};
|
||||
Ok(CommitDetails {
|
||||
sha: sha.into(),
|
||||
message: "initial commit".into(),
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ pub struct Metadata {
|
|||
pub len: u64,
|
||||
pub is_fifo: bool,
|
||||
pub is_executable: bool,
|
||||
pub is_writable: bool,
|
||||
}
|
||||
|
||||
/// Filesystem modification time. The purpose of this newtype is to discourage use of operations
|
||||
|
|
@ -1025,6 +1026,7 @@ impl Fs for RealFs {
|
|||
is_dir: metadata.file_type().is_dir(),
|
||||
is_fifo,
|
||||
is_executable,
|
||||
is_writable: !metadata.permissions().readonly(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -3085,6 +3087,7 @@ impl Fs for FakeFs {
|
|||
is_symlink,
|
||||
is_fifo: false,
|
||||
is_executable: false,
|
||||
is_writable: true,
|
||||
},
|
||||
FakeFsEntry::Dir {
|
||||
inode, mtime, len, ..
|
||||
|
|
@ -3096,6 +3099,7 @@ impl Fs for FakeFs {
|
|||
is_symlink,
|
||||
is_fifo: false,
|
||||
is_executable: false,
|
||||
is_writable: true,
|
||||
},
|
||||
FakeFsEntry::Symlink { .. } => unreachable!(),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -56,13 +56,22 @@ impl FsWatcher {
|
|||
log::trace!("path to watch is already watched: {path:?}");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(registration) = register_existing_path(
|
||||
path,
|
||||
match register_existing_path(
|
||||
path.clone(),
|
||||
case_insensitive,
|
||||
self.tx.clone(),
|
||||
self.pending_path_events.clone(),
|
||||
)? {
|
||||
self.registrations.lock().insert(key, registration);
|
||||
Some(registration) => {
|
||||
self.registrations.lock().insert(key, registration);
|
||||
}
|
||||
None => {
|
||||
// Registration was skipped (e.g. the native watch-limit cooldown
|
||||
// is active). Retry in the background rather than silently leaving
|
||||
// the path unwatched forever.
|
||||
log::warn!("watch registration for {path:?} was skipped; retrying in background");
|
||||
self.add_pending_path(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ pub const GITIGNORE: &str = ".gitignore";
|
|||
pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
|
||||
pub const LFS_DIR: &str = "lfs";
|
||||
pub const OBJECTS_DIR: &str = "objects";
|
||||
pub const REFS_DIR: &str = "refs";
|
||||
pub const REFTABLE_DIR: &str = "reftable";
|
||||
pub const HOOKS_DIR: &str = "hooks";
|
||||
pub const LOGS_DIR: &str = "logs";
|
||||
pub const LOGS_REF_STASH: &str = "logs/refs/stash";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use smallvec::SmallVec;
|
|||
use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
|
||||
use text::LineEnding;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
|
@ -757,20 +758,22 @@ pub enum LogSource {
|
|||
}
|
||||
|
||||
impl LogSource {
|
||||
fn get_args(&self) -> Result<Vec<&str>> {
|
||||
fn get_args(&self) -> Vec<Cow<'_, str>> {
|
||||
match self {
|
||||
LogSource::All => Ok(vec![
|
||||
"--ignore-missing", // needed in case of unborn HEAD
|
||||
"--branches",
|
||||
"--remotes",
|
||||
"--tags",
|
||||
"HEAD",
|
||||
]),
|
||||
LogSource::Branch(branch) => Ok(vec![branch.as_str()]),
|
||||
LogSource::Sha(oid) => Ok(vec![
|
||||
str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")?,
|
||||
]),
|
||||
LogSource::Path(path) => Ok(vec!["--follow", "--", path.as_unix_str()]),
|
||||
LogSource::All => vec![
|
||||
Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD
|
||||
Cow::Borrowed("--branches"),
|
||||
Cow::Borrowed("--remotes"),
|
||||
Cow::Borrowed("--tags"),
|
||||
Cow::Borrowed("HEAD"),
|
||||
],
|
||||
LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())],
|
||||
LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())],
|
||||
LogSource::Path(path) => vec![
|
||||
Cow::Borrowed("--follow"),
|
||||
Cow::Borrowed("--"),
|
||||
Cow::Borrowed(path.as_unix_str()),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3111,8 +3114,9 @@ impl GitRepository for RealGitRepository {
|
|||
let git = self.git_binary();
|
||||
|
||||
async move {
|
||||
let log_source_args = log_source.get_args();
|
||||
let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
|
||||
git_log_command.extend(log_source.get_args()?);
|
||||
git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref()));
|
||||
let mut command = git.build_command(&git_log_command);
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::piped());
|
||||
|
|
@ -3182,6 +3186,7 @@ impl GitRepository for RealGitRepository {
|
|||
let git = self.git_binary();
|
||||
|
||||
async move {
|
||||
let log_source_args = log_source.get_args();
|
||||
let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
|
||||
let hash_query = commit_hash_search_query(search_args.query.as_str())
|
||||
.map(|query| query.to_ascii_lowercase());
|
||||
|
|
@ -3197,7 +3202,7 @@ impl GitRepository for RealGitRepository {
|
|||
args.push(search_args.query.as_str());
|
||||
}
|
||||
|
||||
args.extend(log_source.get_args()?);
|
||||
args.extend(log_source_args.iter().map(|arg| arg.as_ref()));
|
||||
let mut command = git.build_command(&args);
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::null());
|
||||
|
|
@ -3942,7 +3947,7 @@ mod tests {
|
|||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
#[track_caller]
|
||||
fn git_command<I, S>(working_directory: &Path, arguments: I)
|
||||
fn git_command_output<I, S>(working_directory: &Path, arguments: I) -> String
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
|
|
@ -3963,6 +3968,19 @@ mod tests {
|
|||
"git command failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8(output.stdout)
|
||||
.expect("git command output was not valid UTF-8")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn git_command<I, S>(working_directory: &Path, arguments: I)
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
git_command_output(working_directory, arguments);
|
||||
}
|
||||
|
||||
fn git_init_repo(path: &Path) {
|
||||
|
|
@ -4481,6 +4499,42 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) {
|
||||
disable_git_global_config();
|
||||
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
git_init_repo(repo_dir.path());
|
||||
fs::write(repo_dir.path().join("file"), "initial").unwrap();
|
||||
git_command(repo_dir.path(), ["add", "file"]);
|
||||
git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]);
|
||||
|
||||
let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"])
|
||||
.parse()
|
||||
.unwrap();
|
||||
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (request_tx, request_rx) = async_channel::unbounded();
|
||||
|
||||
repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let graph_data = request_rx.recv().await.unwrap();
|
||||
assert_eq!(graph_data.len(), 1);
|
||||
assert_eq!(graph_data[0].sha, commit_sha);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
|
|
|
|||
|
|
@ -365,6 +365,28 @@ impl DiffMultibuffer {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_selected_hunks(
|
||||
&mut self,
|
||||
move_to_next: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let editor = self.editor.read(cx).rhs_editor().clone();
|
||||
let ranges = self.hunk_action_ranges(cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let hunks: Vec<_> = editor.diff_hunks_in_ranges(&ranges, &snapshot).collect();
|
||||
if !hunks.is_empty() {
|
||||
editor.apply_restore(hunks, window, cx);
|
||||
}
|
||||
});
|
||||
if move_to_next {
|
||||
editor
|
||||
.focus_handle(cx)
|
||||
.dispatch_action(&GoToHunk, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_editor_event(
|
||||
&mut self,
|
||||
editor: &Entity<SplittableEditor>,
|
||||
|
|
|
|||
|
|
@ -1210,32 +1210,32 @@ pub fn open_or_reuse_graph(
|
|||
graph.repo_id == repo_id && graph.log_source == log_source
|
||||
});
|
||||
|
||||
if let Some(existing) = existing {
|
||||
if let Some(sha) = sha {
|
||||
existing.update(cx, |graph, cx| {
|
||||
let git_graph = if let Some(existing) = existing {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
existing
|
||||
} else {
|
||||
let workspace_handle = workspace.weak_handle();
|
||||
let git_graph = cx.new(|cx| {
|
||||
GitGraph::new(
|
||||
repo_id,
|
||||
git_store,
|
||||
workspace_handle,
|
||||
Some(log_source),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
|
||||
git_graph
|
||||
};
|
||||
|
||||
if let Some(sha) = sha {
|
||||
cx.defer(move |cx| {
|
||||
git_graph.update(cx, |graph, cx| {
|
||||
graph.select_commit_by_sha(sha.as_str(), cx);
|
||||
});
|
||||
}
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
let workspace_handle = workspace.weak_handle();
|
||||
let git_graph = cx.new(|cx| {
|
||||
let mut graph = GitGraph::new(
|
||||
repo_id,
|
||||
git_store,
|
||||
workspace_handle,
|
||||
Some(log_source),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
if let Some(sha) = sha {
|
||||
graph.select_commit_by_sha(sha.as_str(), cx);
|
||||
}
|
||||
graph
|
||||
});
|
||||
workspace.add_item_to_active_pane(Box::new(git_graph), None, true, window, cx);
|
||||
}
|
||||
|
||||
fn lane_center_x(bounds: Bounds<Pixels>, lane: f32) -> Pixels {
|
||||
|
|
@ -6911,6 +6911,106 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_at_commit_reuses_loaded_graph(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
Path::new("/project"),
|
||||
json!({ ".git": {}, "file.txt": "content" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA");
|
||||
let second_sha = Oid::from_bytes(&[2; 20]).expect("valid commit SHA");
|
||||
fs.set_graph_commits(
|
||||
Path::new("/project/.git"),
|
||||
vec![
|
||||
Arc::new(InitialGraphCommitData {
|
||||
sha: second_sha,
|
||||
parents: smallvec![first_sha],
|
||||
ref_names: vec!["HEAD -> main".into()],
|
||||
}),
|
||||
Arc::new(InitialGraphCommitData {
|
||||
sha: first_sha,
|
||||
parents: smallvec![],
|
||||
ref_names: Vec::new(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
fs.set_commit_data(
|
||||
Path::new("/project/.git"),
|
||||
[first_sha, second_sha].map(|sha| {
|
||||
(
|
||||
CommitData {
|
||||
sha,
|
||||
parents: smallvec![],
|
||||
author_name: "Author".into(),
|
||||
author_email: "author@example.com".into(),
|
||||
commit_timestamp: 1_700_000_000,
|
||||
subject: "Commit subject".into(),
|
||||
message: "Commit message".into(),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}),
|
||||
);
|
||||
|
||||
let project = Project::test(fs, [Path::new("/project")], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let repository = project.read_with(cx, |project, cx| {
|
||||
project
|
||||
.active_repository(cx)
|
||||
.expect("should have a repository")
|
||||
});
|
||||
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
|
||||
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
|
||||
});
|
||||
let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
|
||||
let git_graph = cx.new_window_entity(|window, cx| {
|
||||
GitGraph::new(
|
||||
repository.read(cx).id,
|
||||
project.read(cx).git_store().clone(),
|
||||
workspace.downgrade(),
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
git_graph.update(cx, |graph, cx| {
|
||||
graph.select_commit_by_sha(first_sha, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
git_graph.update(cx, |graph, cx| {
|
||||
graph.select_commit_by_sha(second_sha, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
open_or_reuse_graph(
|
||||
workspace,
|
||||
repository.read(cx).id,
|
||||
project.read(cx).git_store().clone(),
|
||||
LogSource::All,
|
||||
Some(first_sha.to_string()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
git_graph.read_with(&*cx, |graph, _| {
|
||||
assert_eq!(graph.selected_entry_idx, Some(1));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_git_graph_navigation(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -198,13 +198,21 @@ impl StagedDiff {
|
|||
|
||||
if let Some(entry) = entry {
|
||||
staged_diff.update(cx, |staged_diff, cx| {
|
||||
staged_diff
|
||||
.diff
|
||||
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
|
||||
staged_diff.move_to_entry(entry, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_entry(
|
||||
&mut self,
|
||||
entry: GitStatusEntry,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.diff
|
||||
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
project: Entity<Project>,
|
||||
workspace: Entity<Workspace>,
|
||||
|
|
|
|||
|
|
@ -81,12 +81,38 @@ impl DiffHunkDelegate for UnstagedDiffDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
fn restore(
|
||||
&self,
|
||||
hunks: Vec<ResolvedDiffHunks>,
|
||||
editor: &mut Editor,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
if hunks.is_empty() || editor.read_only(cx) {
|
||||
return;
|
||||
}
|
||||
editor.transact(window, cx, |editor, window, cx| {
|
||||
editor.restore_diff_hunks(hunks, cx);
|
||||
let selections = editor
|
||||
.selections
|
||||
.all::<editor::MultiBufferOffset>(&editor.display_snapshot(cx));
|
||||
editor.change_selections(
|
||||
editor::SelectionEffects::no_scroll(),
|
||||
window,
|
||||
cx,
|
||||
|selections_state| {
|
||||
selections_state.select(selections);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_hunk_controls(
|
||||
&self,
|
||||
row: u32,
|
||||
status: &DiffHunkStatus,
|
||||
hunk_range: Range<editor::Anchor>,
|
||||
_is_created_file: bool,
|
||||
is_created_file: bool,
|
||||
line_height: Pixels,
|
||||
editor: &Entity<Editor>,
|
||||
_window: &mut Window,
|
||||
|
|
@ -98,6 +124,7 @@ impl DiffHunkDelegate for UnstagedDiffDelegate {
|
|||
{
|
||||
return gpui::Empty.into_any_element();
|
||||
}
|
||||
let hunk_range_for_restore = hunk_range.clone();
|
||||
let hunk_range = hunk_range.start..hunk_range.start;
|
||||
h_flex()
|
||||
.h(line_height)
|
||||
|
|
@ -130,6 +157,29 @@ impl DiffHunkDelegate for UnstagedDiffDelegate {
|
|||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new(("restore", row as u64), "Restore")
|
||||
.tooltip(Tooltip::text("Restore Hunk"))
|
||||
.on_click({
|
||||
let editor = editor.clone();
|
||||
let hunk_range = hunk_range_for_restore;
|
||||
move |_event, window, cx| {
|
||||
editor.update(cx, |editor, cx| {
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let hunks: Vec<_> = editor
|
||||
.diff_hunks_in_ranges(
|
||||
std::slice::from_ref(&hunk_range),
|
||||
&snapshot,
|
||||
)
|
||||
.collect();
|
||||
if !hunks.is_empty() {
|
||||
editor.apply_restore(hunks, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.disabled(is_created_file),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
|
@ -202,13 +252,21 @@ impl UnstagedDiff {
|
|||
|
||||
if let Some(entry) = entry {
|
||||
unstaged_diff.update(cx, |unstaged_diff, cx| {
|
||||
unstaged_diff
|
||||
.diff
|
||||
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
|
||||
unstaged_diff.move_to_entry(entry, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn move_to_entry(
|
||||
&mut self,
|
||||
entry: GitStatusEntry,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.diff
|
||||
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
project: Entity<Project>,
|
||||
workspace: Entity<Workspace>,
|
||||
|
|
@ -270,6 +328,9 @@ impl UnstagedDiff {
|
|||
.diff_hunks_in_ranges(&ranges, &snapshot)
|
||||
.next()
|
||||
.is_some();
|
||||
let restore = editor
|
||||
.diff_hunks_in_ranges(&ranges, &snapshot)
|
||||
.any(|h| !h.is_created_file());
|
||||
let mut stage_all = false;
|
||||
self.workspace
|
||||
.read_with(cx, |workspace, cx| {
|
||||
|
|
@ -278,9 +339,12 @@ impl UnstagedDiff {
|
|||
}
|
||||
})
|
||||
.ok();
|
||||
let restore_all = snapshot.diff_hunks().any(|h| !h.is_created_file());
|
||||
|
||||
ButtonStates {
|
||||
stage,
|
||||
restore,
|
||||
restore_all,
|
||||
prev_next,
|
||||
selection,
|
||||
stage_all,
|
||||
|
|
@ -297,10 +361,23 @@ impl UnstagedDiff {
|
|||
diff.stage_or_unstage_selected_hunks(true, move_to_next, window, cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn restore_selected_unstaged_hunks(
|
||||
&mut self,
|
||||
move_to_next: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.diff.update(cx, |diff, cx| {
|
||||
diff.restore_selected_hunks(move_to_next, window, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct ButtonStates {
|
||||
stage: bool,
|
||||
restore: bool,
|
||||
restore_all: bool,
|
||||
prev_next: bool,
|
||||
selection: bool,
|
||||
stage_all: bool,
|
||||
|
|
@ -579,6 +656,20 @@ impl UnstagedDiffToolbar {
|
|||
});
|
||||
}
|
||||
|
||||
fn restore_selected_unstaged_hunks(
|
||||
&mut self,
|
||||
move_to_next: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(unstaged_diff) = self.unstaged_diff(cx) else {
|
||||
return;
|
||||
};
|
||||
unstaged_diff.update(cx, |unstaged_diff, cx| {
|
||||
unstaged_diff.restore_selected_unstaged_hunks(move_to_next, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
|
|
@ -591,6 +682,24 @@ impl UnstagedDiffToolbar {
|
|||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn restore_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(unstaged_diff) = self.unstaged_diff(cx) else {
|
||||
return;
|
||||
};
|
||||
let diff = unstaged_diff.read(cx).diff.read(cx);
|
||||
let editor = diff.editor().read(cx).rhs_editor().clone();
|
||||
let snapshot = diff.multibuffer().read(cx).snapshot(cx);
|
||||
let hunks: Vec<_> = snapshot
|
||||
.diff_hunks()
|
||||
.filter(|h| !h.is_created_file())
|
||||
.collect();
|
||||
if !hunks.is_empty() {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.apply_restore(hunks, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for UnstagedDiffToolbar {}
|
||||
|
|
@ -704,7 +813,15 @@ impl Render for UnstagedDiffToolbar {
|
|||
this.stage_selected_unstaged_hunks(true, window, cx)
|
||||
})),
|
||||
)
|
||||
}),
|
||||
})
|
||||
.child(
|
||||
Button::new("restore", "Restore")
|
||||
.disabled(!button_states.restore)
|
||||
.tooltip(Tooltip::text("Restore Selected Hunks"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.restore_selected_unstaged_hunks(false, window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(Divider::vertical())
|
||||
.child(
|
||||
|
|
@ -718,5 +835,13 @@ impl Render for UnstagedDiffToolbar {
|
|||
))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.stage_all(window, cx))),
|
||||
)
|
||||
.child(Divider::vertical())
|
||||
.child(
|
||||
Button::new("restore-all", "Restore All")
|
||||
.width(rems_from_px(80.))
|
||||
.disabled(!button_states.restore_all)
|
||||
.tooltip(Tooltip::text("Restore All Changes"))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.restore_all(window, cx))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ use anyhow::Result;
|
|||
use futures::{Stream, StreamExt};
|
||||
use language_model_core::{
|
||||
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest,
|
||||
LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role,
|
||||
StopReason, TokenUsage,
|
||||
LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolUse,
|
||||
LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason,
|
||||
TokenUsage,
|
||||
};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -20,20 +21,18 @@ pub fn into_google(
|
|||
mut request: LanguageModelRequest,
|
||||
model_id: String,
|
||||
mode: GoogleModelMode,
|
||||
) -> crate::GenerateContentRequest {
|
||||
fn map_content(content: Vec<MessageContent>) -> Vec<Part> {
|
||||
content
|
||||
.into_iter()
|
||||
.flat_map(|content| match content {
|
||||
) -> Result<crate::GenerateContentRequest> {
|
||||
fn map_content(content: Vec<MessageContent>) -> Result<Vec<Part>> {
|
||||
let mut mapped_parts = Vec::new();
|
||||
for content in content {
|
||||
match content {
|
||||
MessageContent::Text(text) => {
|
||||
if !text.is_empty() {
|
||||
vec![Part::TextPart(TextPart {
|
||||
mapped_parts.push(Part::TextPart(TextPart {
|
||||
text,
|
||||
thought: false,
|
||||
thought_signature: None,
|
||||
})]
|
||||
} else {
|
||||
vec![]
|
||||
}));
|
||||
}
|
||||
}
|
||||
MessageContent::Thinking {
|
||||
|
|
@ -41,39 +40,37 @@ pub fn into_google(
|
|||
signature: Some(signature),
|
||||
} => {
|
||||
if !signature.is_empty() {
|
||||
vec![Part::TextPart(TextPart {
|
||||
mapped_parts.push(Part::TextPart(TextPart {
|
||||
text,
|
||||
thought: true,
|
||||
thought_signature: Some(signature),
|
||||
})]
|
||||
} else {
|
||||
vec![]
|
||||
}));
|
||||
}
|
||||
}
|
||||
MessageContent::Thinking { .. } => {
|
||||
vec![]
|
||||
}
|
||||
MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => vec![],
|
||||
MessageContent::Thinking { .. } => {}
|
||||
MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => {}
|
||||
MessageContent::Image(image) => {
|
||||
vec![Part::InlineDataPart(InlineDataPart {
|
||||
mapped_parts.push(Part::InlineDataPart(InlineDataPart {
|
||||
inline_data: GenerativeContentBlob {
|
||||
mime_type: "image/png".to_string(),
|
||||
data: image.source.to_string(),
|
||||
},
|
||||
})]
|
||||
}));
|
||||
}
|
||||
MessageContent::ToolUse(tool_use) => {
|
||||
// Normalize empty string signatures to None
|
||||
let thought_signature = tool_use.thought_signature.filter(|s| !s.is_empty());
|
||||
let LanguageModelToolUseInput::Json(input) = tool_use.input else {
|
||||
anyhow::bail!("Google AI does not support custom tool calls");
|
||||
};
|
||||
|
||||
vec![Part::FunctionCallPart(crate::FunctionCallPart {
|
||||
mapped_parts.push(Part::FunctionCallPart(crate::FunctionCallPart {
|
||||
function_call: crate::FunctionCall {
|
||||
name: tool_use.name.to_string(),
|
||||
args: tool_use.input,
|
||||
args: input,
|
||||
id: Some(tool_use.id.to_string()),
|
||||
},
|
||||
thought_signature,
|
||||
})]
|
||||
}));
|
||||
}
|
||||
MessageContent::ToolResult(tool_result) => {
|
||||
let mut text_output = String::new();
|
||||
|
|
@ -98,7 +95,7 @@ pub fn into_google(
|
|||
} else {
|
||||
text_output
|
||||
};
|
||||
let mut parts = vec![Part::FunctionResponsePart(crate::FunctionResponsePart {
|
||||
mapped_parts.push(Part::FunctionResponsePart(crate::FunctionResponsePart {
|
||||
function_response: crate::FunctionResponse {
|
||||
name: tool_result.tool_name.to_string(),
|
||||
// The API expects a valid JSON object
|
||||
|
|
@ -107,12 +104,12 @@ pub fn into_google(
|
|||
}),
|
||||
id: Some(tool_result.tool_use_id.to_string()),
|
||||
},
|
||||
})];
|
||||
parts.extend(images.into_iter().map(Part::InlineDataPart));
|
||||
parts
|
||||
}));
|
||||
mapped_parts.extend(images.into_iter().map(Part::InlineDataPart));
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
Ok(mapped_parts)
|
||||
}
|
||||
|
||||
let thinking_config = thinking_config_for_request(&request, &model_id, mode);
|
||||
|
|
@ -124,33 +121,59 @@ pub fn into_google(
|
|||
{
|
||||
let message = request.messages.remove(0);
|
||||
Some(SystemInstruction {
|
||||
parts: map_content(message.content),
|
||||
parts: map_content(message.content)?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
crate::GenerateContentRequest {
|
||||
let tools = if request.tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(vec![crate::Tool {
|
||||
function_declarations: request
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| match tool.input {
|
||||
LanguageModelRequestToolInput::Function { input_schema, .. } => {
|
||||
Ok(FunctionDeclaration {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: input_schema,
|
||||
})
|
||||
}
|
||||
LanguageModelRequestToolInput::Custom { .. } => {
|
||||
Err(anyhow::anyhow!("Google AI does not support custom tools"))
|
||||
}
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
}])
|
||||
};
|
||||
|
||||
Ok(crate::GenerateContentRequest {
|
||||
model: ModelName { model_id },
|
||||
system_instruction: system_instructions,
|
||||
contents: request
|
||||
.messages
|
||||
.into_iter()
|
||||
.filter_map(|message| {
|
||||
let parts = map_content(message.content);
|
||||
.map(|message| {
|
||||
let parts = map_content(message.content)?;
|
||||
if parts.is_empty() {
|
||||
None
|
||||
Ok(None)
|
||||
} else {
|
||||
Some(Content {
|
||||
Ok(Some(Content {
|
||||
parts,
|
||||
role: match message.role {
|
||||
Role::User => crate::Role::User,
|
||||
Role::Assistant => crate::Role::Model,
|
||||
Role::System => crate::Role::User, // Google AI doesn't have a system role
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
generation_config: Some(GenerationConfig {
|
||||
candidate_count: Some(1),
|
||||
|
|
@ -162,19 +185,7 @@ pub fn into_google(
|
|||
top_k: None,
|
||||
}),
|
||||
safety_settings: None,
|
||||
tools: (!request.tools.is_empty()).then(|| {
|
||||
vec![crate::Tool {
|
||||
function_declarations: request
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| FunctionDeclaration {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.input_schema,
|
||||
})
|
||||
.collect(),
|
||||
}]
|
||||
}),
|
||||
tools,
|
||||
tool_config: request.tool_choice.map(|choice| ToolConfig {
|
||||
function_calling_config: FunctionCallingConfig {
|
||||
mode: match choice {
|
||||
|
|
@ -185,7 +196,7 @@ pub fn into_google(
|
|||
allowed_function_names: None,
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn thinking_config_for_request(
|
||||
|
|
@ -404,7 +415,9 @@ impl GoogleEventMapper {
|
|||
name,
|
||||
is_input_complete: true,
|
||||
raw_input: function_call_part.function_call.args.to_string(),
|
||||
input: function_call_part.function_call.args,
|
||||
input: LanguageModelToolUseInput::Json(
|
||||
function_call_part.function_call.args,
|
||||
),
|
||||
thought_signature,
|
||||
},
|
||||
)));
|
||||
|
|
@ -493,7 +506,8 @@ mod tests {
|
|||
GoogleModelMode::Thinking {
|
||||
budget_tokens: None,
|
||||
},
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
|
||||
assert_eq!(thinking_config.include_thoughts, Some(true));
|
||||
|
|
@ -515,7 +529,8 @@ mod tests {
|
|||
GoogleModelMode::Thinking {
|
||||
budget_tokens: None,
|
||||
},
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
|
||||
assert_eq!(thinking_config.thinking_budget, Some(0));
|
||||
|
|
@ -533,7 +548,8 @@ mod tests {
|
|||
GoogleModelMode::Thinking {
|
||||
budget_tokens: None,
|
||||
},
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
|
||||
assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Minimal));
|
||||
|
|
@ -561,7 +577,8 @@ mod tests {
|
|||
GoogleModelMode::Thinking {
|
||||
budget_tokens: None,
|
||||
},
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let Part::TextPart(text_part) = &request.contents[0].parts[0] else {
|
||||
panic!("expected text part");
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ test-support = [
|
|||
bench = ["test-support", "dep:criterion", "dep:hdrhistogram"]
|
||||
inspector = ["gpui_macros/inspector"]
|
||||
leak-detection = ["backtrace"]
|
||||
wayland = [
|
||||
"bitflags",
|
||||
]
|
||||
wayland = []
|
||||
x11 = [
|
||||
"scap?/x11",
|
||||
]
|
||||
|
|
@ -51,7 +49,7 @@ accesskit.workspace = true
|
|||
anyhow.workspace = true
|
||||
async-task = "4.7"
|
||||
backtrace = { workspace = true, optional = true }
|
||||
bitflags = { workspace = true, optional = true }
|
||||
bitflags.workspace = true
|
||||
|
||||
collections.workspace = true
|
||||
criterion = { workspace = true, optional = true }
|
||||
|
|
@ -256,3 +254,7 @@ path = "examples/mouse_pressure.rs"
|
|||
[[example]]
|
||||
name = "a11y"
|
||||
path = "examples/a11y.rs"
|
||||
|
||||
[[example]]
|
||||
name = "view_example"
|
||||
path = "examples/view_example/view_example_main.rs"
|
||||
|
|
|
|||
|
|
@ -1,68 +1,63 @@
|
|||
#![cfg_attr(target_family = "wasm", no_main)]
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size,
|
||||
App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, container_query, div,
|
||||
prelude::*, px, rgb, size,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
|
||||
// https://en.wikipedia.org/wiki/Holy_grail_(web_design)
|
||||
//
|
||||
// Resize the window: the layout is chosen by `container_query` based on the
|
||||
// measured size of the container, collapsing to a single stacked column when
|
||||
// it becomes too narrow for the three-column grid.
|
||||
struct HolyGrailExample {}
|
||||
|
||||
impl Render for HolyGrailExample {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let block = |color: Hsla| {
|
||||
div()
|
||||
.size_full()
|
||||
.bg(color)
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.rounded_md()
|
||||
.border_color(gpui::white())
|
||||
.items_center()
|
||||
};
|
||||
container_query(|container_size, _window, _cx| {
|
||||
let block = |color: Hsla| {
|
||||
div()
|
||||
.size_full()
|
||||
.bg(color)
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.rounded_md()
|
||||
.border_color(gpui::white())
|
||||
.items_center()
|
||||
};
|
||||
|
||||
div()
|
||||
.gap_1()
|
||||
.grid()
|
||||
.bg(rgb(0x505050))
|
||||
.size(px(500.0))
|
||||
.shadow_lg()
|
||||
.border_1()
|
||||
.size_full()
|
||||
.grid_cols(5)
|
||||
.grid_rows(5)
|
||||
.child(
|
||||
block(gpui::white())
|
||||
.row_span(1)
|
||||
.col_span_full()
|
||||
.child("Header"),
|
||||
)
|
||||
.child(
|
||||
block(gpui::red())
|
||||
.col_span(1)
|
||||
.h_56()
|
||||
.child("Table of contents"),
|
||||
)
|
||||
.child(
|
||||
block(gpui::green())
|
||||
.col_span(3)
|
||||
.row_span(3)
|
||||
.child("Content"),
|
||||
)
|
||||
.child(
|
||||
block(gpui::blue())
|
||||
.col_span(1)
|
||||
.row_span(3)
|
||||
.child("AD :(")
|
||||
.text_color(gpui::white()),
|
||||
)
|
||||
.child(
|
||||
block(gpui::black())
|
||||
.row_span(1)
|
||||
.col_span_full()
|
||||
.text_color(gpui::white())
|
||||
.child("Footer"),
|
||||
)
|
||||
let header = block(gpui::white()).child(format!("Header — {}", container_size.width));
|
||||
let table_of_contents = block(gpui::red()).child("Table of contents");
|
||||
let content = block(gpui::green()).child("Content");
|
||||
let ad = block(gpui::blue()).child("AD :(").text_color(gpui::white());
|
||||
let footer = block(gpui::black())
|
||||
.text_color(gpui::white())
|
||||
.child("Footer");
|
||||
|
||||
let container = div().gap_1().bg(rgb(0x505050)).shadow_lg().size_full();
|
||||
|
||||
if container_size.width < px(400.) {
|
||||
container
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(header.h_12().flex_none())
|
||||
.child(table_of_contents.h_20().flex_none())
|
||||
.child(content.flex_1())
|
||||
.child(ad.h_20().flex_none())
|
||||
.child(footer.h_12().flex_none())
|
||||
} else {
|
||||
container
|
||||
.grid()
|
||||
.grid_cols(5)
|
||||
.grid_rows(5)
|
||||
.child(header.row_span(1).col_span_full())
|
||||
.child(table_of_contents.col_span(1).h_56())
|
||||
.child(content.col_span(3).row_span(3))
|
||||
.child(ad.col_span(1).row_span(3))
|
||||
.child(footer.row_span(1).col_span_full())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
549
crates/gpui/examples/view_example/example_editor.rs
Normal file
549
crates/gpui/examples/view_example/example_editor.rs
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard
|
||||
//! handling, and the specialized text-shaping renderer. The *text itself* lives
|
||||
//! in a shared `Entity<String>` it's handed at construction, so the value is
|
||||
//! readable/writable from outside while the editing machinery stays in here.
|
||||
//!
|
||||
//! This is the piece that proves the point: a text input is genuinely
|
||||
//! complicated, and `View` lets all of that complexity live in one entity that
|
||||
//! anything can embed.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable,
|
||||
InteractiveElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Subscription, Task,
|
||||
TextRun, UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size,
|
||||
};
|
||||
use unicode_segmentation::*;
|
||||
|
||||
use crate::{Backspace, Delete, End, Home, Left, Right};
|
||||
|
||||
pub struct Editor {
|
||||
pub value: Entity<String>,
|
||||
pub focus_handle: FocusHandle,
|
||||
pub cursor: usize,
|
||||
pub cursor_visible: bool,
|
||||
_blink_task: Task<()>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
/// An editor that owns its own string internally, seeded with `text`.
|
||||
/// Nothing to allocate or wire up at the call site.
|
||||
pub fn new(text: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let value = cx.new(|_| text.into());
|
||||
Self::over(value, window, cx)
|
||||
}
|
||||
|
||||
/// An editor over a string *you* own, so the value is shared in and out.
|
||||
pub fn over(value: Entity<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| {
|
||||
this.start_blink(cx);
|
||||
});
|
||||
let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| {
|
||||
this.stop_blink(cx);
|
||||
});
|
||||
|
||||
// The value is shared: anything can write it while we hold a cursor into
|
||||
// it. Observe it so external writes (a) clamp the cursor back onto a char
|
||||
// boundary before the next IME round-trip can slice out of bounds, and
|
||||
// (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache
|
||||
// is keyed on *our* notify, not the value's.
|
||||
let value_sub = cx.observe(&value, |this, value, cx| {
|
||||
let content = value.read(cx);
|
||||
let mut cursor = this.cursor.min(content.len());
|
||||
while cursor > 0 && !content.is_char_boundary(cursor) {
|
||||
cursor -= 1;
|
||||
}
|
||||
this.cursor = cursor;
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
value,
|
||||
focus_handle,
|
||||
cursor: 0,
|
||||
cursor_visible: false,
|
||||
_blink_task: Task::ready(()),
|
||||
_subscriptions: vec![focus_sub, blur_sub, value_sub],
|
||||
}
|
||||
}
|
||||
|
||||
/// The current text. Read this from anywhere to get the value out.
|
||||
pub fn text(&self, cx: &App) -> String {
|
||||
self.value.read(cx).clone()
|
||||
}
|
||||
|
||||
fn start_blink(&mut self, cx: &mut Context<Self>) {
|
||||
self.cursor_visible = true;
|
||||
self._blink_task = Self::spawn_blink_task(cx);
|
||||
}
|
||||
|
||||
fn stop_blink(&mut self, cx: &mut Context<Self>) {
|
||||
self.cursor_visible = false;
|
||||
self._blink_task = Task::ready(());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn spawn_blink_task(cx: &mut Context<Self>) -> Task<()> {
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(500))
|
||||
.await;
|
||||
let result = this.update(cx, |editor, cx| {
|
||||
editor.cursor_visible = !editor.cursor_visible;
|
||||
cx.notify();
|
||||
});
|
||||
if result.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn reset_blink(&mut self, cx: &mut Context<Self>) {
|
||||
self.cursor_visible = true;
|
||||
self._blink_task = Self::spawn_blink_task(cx);
|
||||
}
|
||||
|
||||
pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.text(cx);
|
||||
if self.cursor > 0 {
|
||||
self.cursor = previous_boundary(&content, self.cursor);
|
||||
}
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.text(cx);
|
||||
if self.cursor < content.len() {
|
||||
self.cursor = next_boundary(&content, self.cursor);
|
||||
}
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.cursor = 0;
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.cursor = self.text(cx).len();
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.text(cx);
|
||||
if self.cursor > 0 {
|
||||
let prev = previous_boundary(&content, self.cursor);
|
||||
let cursor = self.cursor;
|
||||
self.value.update(cx, |s, cx| {
|
||||
s.drain(prev..cursor);
|
||||
cx.notify();
|
||||
});
|
||||
self.cursor = prev;
|
||||
}
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.text(cx);
|
||||
if self.cursor < content.len() {
|
||||
let next = next_boundary(&content, self.cursor);
|
||||
let cursor = self.cursor;
|
||||
self.value.update(cx, |s, cx| {
|
||||
s.drain(cursor..next);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn insert_newline(&mut self, cx: &mut Context<Self>) {
|
||||
let cursor = self.cursor;
|
||||
self.value.update(cx, |s, cx| {
|
||||
s.insert(cursor, '\n');
|
||||
cx.notify();
|
||||
});
|
||||
self.cursor += 1;
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_boundary(content: &str, offset: usize) -> usize {
|
||||
content
|
||||
.grapheme_indices(true)
|
||||
.rev()
|
||||
.find_map(|(idx, _)| (idx < offset).then_some(idx))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn next_boundary(content: &str, offset: usize) -> usize {
|
||||
content
|
||||
.grapheme_indices(true)
|
||||
.find_map(|(idx, _)| (idx > offset).then_some(idx))
|
||||
.unwrap_or(content.len())
|
||||
}
|
||||
|
||||
fn offset_from_utf16(content: &str, offset: usize) -> usize {
|
||||
let mut utf8_offset = 0;
|
||||
let mut utf16_count = 0;
|
||||
for ch in content.chars() {
|
||||
if utf16_count >= offset {
|
||||
break;
|
||||
}
|
||||
utf16_count += ch.len_utf16();
|
||||
utf8_offset += ch.len_utf8();
|
||||
}
|
||||
utf8_offset
|
||||
}
|
||||
|
||||
fn offset_to_utf16(content: &str, offset: usize) -> usize {
|
||||
let mut utf16_offset = 0;
|
||||
let mut utf8_count = 0;
|
||||
for ch in content.chars() {
|
||||
if utf8_count >= offset {
|
||||
break;
|
||||
}
|
||||
utf8_count += ch.len_utf8();
|
||||
utf16_offset += ch.len_utf16();
|
||||
}
|
||||
utf16_offset
|
||||
}
|
||||
|
||||
fn range_to_utf16(content: &str, range: &Range<usize>) -> Range<usize> {
|
||||
offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end)
|
||||
}
|
||||
|
||||
fn range_from_utf16(content: &str, range_utf16: &Range<usize>) -> Range<usize> {
|
||||
offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, range_utf16.end)
|
||||
}
|
||||
|
||||
impl Focusable for Editor {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityInputHandler for Editor {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
actual_range: &mut Option<Range<usize>>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let content = self.text(cx);
|
||||
let range = range_from_utf16(&content, &range_utf16);
|
||||
actual_range.replace(range_to_utf16(&content, &range));
|
||||
Some(content[range].to_string())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
let content = self.text(cx);
|
||||
let utf16_cursor = offset_to_utf16(&content, self.cursor);
|
||||
Some(UTF16Selection {
|
||||
range: utf16_cursor..utf16_cursor,
|
||||
reversed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Range<usize>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let content = self.text(cx);
|
||||
let range = range_utf16
|
||||
.as_ref()
|
||||
.map(|r| range_from_utf16(&content, r))
|
||||
.unwrap_or(self.cursor..self.cursor);
|
||||
|
||||
let new_content = content[..range.start].to_owned() + new_text + &content[range.end..];
|
||||
self.cursor = range.start + new_text.len();
|
||||
self.value.update(cx, |s, cx| {
|
||||
*s = new_content;
|
||||
cx.notify();
|
||||
});
|
||||
self.reset_blink(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
_new_selected_range_utf16: Option<Range<usize>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.replace_text_in_range(range_utf16, new_text, window, cx);
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
_range_utf16: Range<usize>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_point: gpui::Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::Render for Editor {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Editor>) -> impl IntoElement {
|
||||
EditorText {
|
||||
editor: cx.entity(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EditorText — the specialized renderer: shapes the text and paints the cursor.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct EditorText {
|
||||
editor: Entity<Editor>,
|
||||
}
|
||||
|
||||
struct EditorTextPrepaint {
|
||||
lines: Vec<ShapedLine>,
|
||||
cursor: Option<PaintQuad>,
|
||||
}
|
||||
|
||||
impl IntoElement for EditorText {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for EditorText {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = EditorTextPrepaint;
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let editor = self.editor.read(cx);
|
||||
let content = editor.value.read(cx);
|
||||
let line_count = content.split('\n').count().max(1);
|
||||
let line_height = window.line_height();
|
||||
let mut style = gpui::Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = (line_height * line_count as f32).into();
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let editor = self.editor.read(cx);
|
||||
let content = editor.value.read(cx).clone();
|
||||
let cursor_offset = editor.cursor;
|
||||
let cursor_visible = editor.cursor_visible;
|
||||
let is_focused = editor.focus_handle.is_focused(window);
|
||||
|
||||
let style = window.text_style();
|
||||
let text_color = style.color;
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
let line_height = window.line_height();
|
||||
|
||||
let is_placeholder = content.is_empty();
|
||||
|
||||
let lines: Vec<ShapedLine> = if is_placeholder {
|
||||
let placeholder: SharedString = "Type here...".into();
|
||||
let run = TextRun {
|
||||
len: placeholder.len(),
|
||||
font: style.font(),
|
||||
color: hsla(0., 0., 0.5, 0.5),
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
vec![
|
||||
window
|
||||
.text_system()
|
||||
.shape_line(placeholder, font_size, &[run], None),
|
||||
]
|
||||
} else {
|
||||
content
|
||||
.split('\n')
|
||||
.map(|line_str| {
|
||||
let text: SharedString = SharedString::from(line_str.to_string());
|
||||
let run = TextRun {
|
||||
len: text.len(),
|
||||
font: style.font(),
|
||||
color: text_color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
window
|
||||
.text_system()
|
||||
.shape_line(text, font_size, &[run], None)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let cursor = if is_focused && cursor_visible && !is_placeholder {
|
||||
let (cursor_line, offset_in_line) = cursor_line_and_offset(&content, cursor_offset);
|
||||
let cursor_line = cursor_line.min(lines.len().saturating_sub(1));
|
||||
let cursor_x = lines[cursor_line].x_for_index(offset_in_line);
|
||||
Some(fill(
|
||||
Bounds::new(
|
||||
point(
|
||||
bounds.left() + cursor_x,
|
||||
bounds.top() + line_height * cursor_line as f32,
|
||||
),
|
||||
size(px(1.5), line_height),
|
||||
),
|
||||
text_color,
|
||||
))
|
||||
} else if is_focused && cursor_visible && is_placeholder {
|
||||
Some(fill(
|
||||
Bounds::new(
|
||||
point(bounds.left(), bounds.top()),
|
||||
size(px(1.5), line_height),
|
||||
),
|
||||
text_color,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
EditorTextPrepaint { lines, cursor }
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let focus_handle = self.editor.read(cx).focus_handle.clone();
|
||||
window.handle_input(
|
||||
&focus_handle,
|
||||
ElementInputHandler::new(bounds, self.editor.clone()),
|
||||
cx,
|
||||
);
|
||||
|
||||
let line_height = window.line_height();
|
||||
for (i, line) in prepaint.lines.iter().enumerate() {
|
||||
let origin = point(bounds.left(), bounds.top() + line_height * i as f32);
|
||||
line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if let Some(cursor) = prepaint.cursor.take() {
|
||||
window.paint_quad(cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) {
|
||||
let mut line_index = 0;
|
||||
let mut line_start = 0;
|
||||
for (i, ch) in content.char_indices() {
|
||||
if i >= cursor {
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
line_index += 1;
|
||||
line_start = i + 1;
|
||||
}
|
||||
}
|
||||
(line_index, cursor - line_start)
|
||||
}
|
||||
|
||||
pub fn standard_actions<E: InteractiveElement>(editor: Entity<Editor>) -> impl FnOnce(E) -> E {
|
||||
move |element| {
|
||||
element
|
||||
.on_action({
|
||||
let editor = editor.clone();
|
||||
move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx))
|
||||
})
|
||||
.on_action({
|
||||
let editor = editor.clone();
|
||||
move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx))
|
||||
})
|
||||
.on_action({
|
||||
let editor = editor.clone();
|
||||
move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx))
|
||||
})
|
||||
.on_action({
|
||||
let editor = editor.clone();
|
||||
move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx))
|
||||
})
|
||||
.on_action({
|
||||
let editor = editor.clone();
|
||||
move |a: &Backspace, window, cx| {
|
||||
editor.update(cx, |e, cx| e.backspace(a, window, cx))
|
||||
}
|
||||
})
|
||||
.on_action(move |a: &Delete, window, cx| {
|
||||
editor.update(cx, |e, cx| e.delete(a, window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
121
crates/gpui/examples/view_example/example_input.rs
Normal file
121
crates/gpui/examples/view_example/example_input.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! `Input` — a single-line text input. The shaping layer over `Editor`.
|
||||
//!
|
||||
//! Construct it two ways, depending on how much state you want to own:
|
||||
//! * `Input::new(value: Entity<String>)` — you hold just the string; the input
|
||||
//! allocates the `Editor` internally via `use_state`. Value readable, cursor hidden.
|
||||
//! * `Input::editor(editor: Entity<Editor>)` — you hold the editor; cursor/selection
|
||||
//! are now yours to read and drive too.
|
||||
//!
|
||||
//! Either way the chrome is identical. Because the string (or editor) is the
|
||||
//! input's *identity*, the internal `use_state(Editor)` is collision-safe across
|
||||
//! any number of inputs.
|
||||
|
||||
use gpui::{
|
||||
App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement,
|
||||
Window, div, hsla, point, prelude::*, px, white,
|
||||
};
|
||||
|
||||
use crate::example_editor::{Editor, standard_actions};
|
||||
|
||||
enum Source {
|
||||
Value(Entity<String>),
|
||||
Editor(Entity<Editor>),
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct Input {
|
||||
source: Source,
|
||||
width: Option<Pixels>,
|
||||
color: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// Backed by a bare string; the editor is allocated internally.
|
||||
pub fn new(value: Entity<String>) -> Self {
|
||||
Self {
|
||||
source: Source::Value(value),
|
||||
width: None,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Backed by an editor you own (so you can read/drive its cursor).
|
||||
pub fn editor(editor: Entity<Editor>) -> Self {
|
||||
Self {
|
||||
source: Source::Editor(editor),
|
||||
width: None,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: Pixels) -> Self {
|
||||
self.width = Some(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Hsla) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::View for Input {
|
||||
fn entity_id(&self) -> Option<EntityId> {
|
||||
Some(match &self.source {
|
||||
Source::Value(value) => value.entity_id(),
|
||||
Source::Editor(editor) => editor.entity_id(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
// Get the editor: use the one we were handed, or allocate it under our
|
||||
// own (string-derived) identity so it persists and never collides.
|
||||
let editor = match self.source {
|
||||
Source::Value(value) => {
|
||||
window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
|
||||
}
|
||||
Source::Editor(editor) => editor,
|
||||
};
|
||||
|
||||
let focus_handle = editor.read(cx).focus_handle.clone();
|
||||
let is_focused = focus_handle.is_focused(window);
|
||||
let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
|
||||
let box_width = self.width.unwrap_or(px(300.));
|
||||
|
||||
let border = if is_focused {
|
||||
hsla(220. / 360., 0.8, 0.5, 1.)
|
||||
} else {
|
||||
hsla(0., 0., 0.75, 1.)
|
||||
};
|
||||
|
||||
div()
|
||||
.id("input")
|
||||
.key_context("TextInput")
|
||||
.track_focus(&focus_handle)
|
||||
.cursor(CursorStyle::IBeam)
|
||||
.map(standard_actions(editor.clone()))
|
||||
.w(box_width)
|
||||
.h(px(36.))
|
||||
.px(px(8.))
|
||||
.bg(white())
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.when(is_focused, |this| {
|
||||
this.shadow(vec![BoxShadow {
|
||||
color: hsla(220. / 360., 0.8, 0.5, 0.3),
|
||||
offset: point(px(0.), px(0.)),
|
||||
blur_radius: px(4.),
|
||||
spread_radius: px(1.),
|
||||
inset: false,
|
||||
}])
|
||||
})
|
||||
.rounded(px(4.))
|
||||
.overflow_hidden()
|
||||
.flex()
|
||||
.items_center()
|
||||
.line_height(px(20.))
|
||||
.text_size(px(14.))
|
||||
.text_color(text_color)
|
||||
.child(editor.cached(StyleRefinement::default().size_full()))
|
||||
}
|
||||
}
|
||||
131
crates/gpui/examples/view_example/example_tests.rs
Normal file
131
crates/gpui/examples/view_example/example_tests.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
//! Tests for the input composition. Require the `test-support` feature:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo test -p gpui --example view_example --features test-support
|
||||
//! ```
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*};
|
||||
|
||||
use crate::example_editor::Editor;
|
||||
use crate::example_input::Input;
|
||||
use crate::{Backspace, Delete, End, Home, Left, Right};
|
||||
|
||||
/// Two inputs, each backed by an editor we own (so the test can focus and
|
||||
/// read them). Proves data flows through the shared `String` and that
|
||||
/// sibling inputs stay isolated.
|
||||
struct Harness {
|
||||
a: Entity<Editor>,
|
||||
b: Entity<Editor>,
|
||||
}
|
||||
|
||||
impl Render for Harness {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
gpui::div()
|
||||
.child(Input::editor(self.a.clone()))
|
||||
.child(Input::editor(self.b.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_keys(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("backspace", Backspace, None),
|
||||
KeyBinding::new("delete", Delete, None),
|
||||
KeyBinding::new("left", Left, None),
|
||||
KeyBinding::new("right", Right, None),
|
||||
KeyBinding::new("home", Home, None),
|
||||
KeyBinding::new("end", End, None),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
fn setup(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
Entity<Editor>,
|
||||
Entity<String>,
|
||||
Entity<String>,
|
||||
&mut gpui::VisualTestContext,
|
||||
) {
|
||||
bind_keys(cx);
|
||||
|
||||
let (harness, cx) = cx.add_window_view(|window, cx| {
|
||||
let a_value = cx.new(|_| String::new());
|
||||
let b_value = cx.new(|_| String::new());
|
||||
let a = cx.new(|cx| Editor::over(a_value, window, cx));
|
||||
let b = cx.new(|cx| Editor::over(b_value, window, cx));
|
||||
Harness { a, b }
|
||||
});
|
||||
|
||||
let a = cx.read_entity(&harness, |h, _| h.a.clone());
|
||||
let b = cx.read_entity(&harness, |h, _| h.b.clone());
|
||||
let a_value = cx.read_entity(&a, |e, _| e.value.clone());
|
||||
let b_value = cx.read_entity(&b, |e, _| e.value.clone());
|
||||
|
||||
// Focus the first input's editor.
|
||||
cx.update(|window, cx| {
|
||||
let focus_handle = a.read(cx).focus_handle.clone();
|
||||
window.focus(&focus_handle, cx);
|
||||
});
|
||||
|
||||
(a, a_value, b_value, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn typing_updates_the_shared_string(cx: &mut TestAppContext) {
|
||||
let (editor, a_value, _b_value, cx) = setup(cx);
|
||||
|
||||
cx.simulate_input("hello");
|
||||
|
||||
cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello"));
|
||||
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn sibling_inputs_are_isolated(cx: &mut TestAppContext) {
|
||||
let (_editor, a_value, b_value, cx) = setup(cx);
|
||||
|
||||
cx.simulate_input("x");
|
||||
|
||||
cx.read_entity(&a_value, |value, _| assert_eq!(value, "x"));
|
||||
cx.read_entity(&b_value, |value, _| {
|
||||
assert_eq!(value, "", "typing in input A must not touch input B")
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) {
|
||||
let (editor, a_value, _b_value, cx) = setup(cx);
|
||||
|
||||
cx.simulate_input("hello");
|
||||
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
|
||||
|
||||
// Write the shared value from outside the editor. The old cursor (5)
|
||||
// now points into the middle of a multi-byte character; the editor's
|
||||
// observation must clamp it back onto a boundary.
|
||||
cx.update(|_, cx| {
|
||||
a_value.update(cx, |value, cx| {
|
||||
*value = "日本".to_string();
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
|
||||
cx.read_entity(&a_value, |value, _| assert_eq!(value, "日本"));
|
||||
cx.read_entity(&editor, |editor, _| {
|
||||
assert_eq!(editor.cursor, 3, "cursor must clamp to a char boundary");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn arrows_move_the_cursor(cx: &mut TestAppContext) {
|
||||
let (editor, _a_value, _b_value, cx) = setup(cx);
|
||||
|
||||
cx.simulate_input("abc");
|
||||
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3));
|
||||
|
||||
cx.simulate_keystrokes("left left");
|
||||
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1));
|
||||
}
|
||||
}
|
||||
118
crates/gpui/examples/view_example/example_text_area.rs
Normal file
118
crates/gpui/examples/view_example/example_text_area.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome,
|
||||
//! and `Enter` inserts a newline instead of being ignored. Constructible from a
|
||||
//! string or an editor, exactly like [`Input`](crate::example_input::Input).
|
||||
|
||||
use gpui::{
|
||||
App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div,
|
||||
hsla, point, prelude::*, px, white,
|
||||
};
|
||||
|
||||
use crate::Enter;
|
||||
use crate::example_editor::{Editor, standard_actions};
|
||||
|
||||
enum Source {
|
||||
Value(Entity<String>),
|
||||
Editor(Entity<Editor>),
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct TextArea {
|
||||
source: Source,
|
||||
rows: usize,
|
||||
color: Option<Hsla>,
|
||||
}
|
||||
|
||||
impl TextArea {
|
||||
pub fn new(value: Entity<String>, rows: usize) -> Self {
|
||||
Self {
|
||||
source: Source::Value(value),
|
||||
rows,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editor(editor: Entity<Editor>, rows: usize) -> Self {
|
||||
Self {
|
||||
source: Source::Editor(editor),
|
||||
rows,
|
||||
color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(mut self, color: Hsla) -> Self {
|
||||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::View for TextArea {
|
||||
fn entity_id(&self) -> Option<EntityId> {
|
||||
Some(match &self.source {
|
||||
Source::Value(value) => value.entity_id(),
|
||||
Source::Editor(editor) => editor.entity_id(),
|
||||
})
|
||||
}
|
||||
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let editor = match self.source {
|
||||
Source::Value(value) => {
|
||||
window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
|
||||
}
|
||||
Source::Editor(editor) => editor,
|
||||
};
|
||||
|
||||
let focus_handle = editor.read(cx).focus_handle.clone();
|
||||
let is_focused = focus_handle.is_focused(window);
|
||||
let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
|
||||
let row_height = px(20.);
|
||||
let box_height = row_height * self.rows as f32 + px(16.);
|
||||
|
||||
let border = if is_focused {
|
||||
hsla(220. / 360., 0.8, 0.5, 1.)
|
||||
} else {
|
||||
hsla(0., 0., 0.75, 1.)
|
||||
};
|
||||
|
||||
div()
|
||||
.id("text-area")
|
||||
.key_context("TextInput")
|
||||
.track_focus(&focus_handle)
|
||||
.cursor(CursorStyle::IBeam)
|
||||
.map(standard_actions(editor.clone()))
|
||||
// Enter is the one binding that differs from a single-line input.
|
||||
.on_action({
|
||||
let editor = editor.clone();
|
||||
move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx))
|
||||
})
|
||||
.w(px(400.))
|
||||
.h(box_height)
|
||||
.p(px(8.))
|
||||
.bg(white())
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
.when(is_focused, |this| {
|
||||
this.shadow(vec![BoxShadow {
|
||||
color: hsla(220. / 360., 0.8, 0.5, 0.3),
|
||||
offset: point(px(0.), px(0.)),
|
||||
blur_radius: px(4.),
|
||||
spread_radius: px(1.),
|
||||
inset: false,
|
||||
}])
|
||||
})
|
||||
.rounded(px(4.))
|
||||
.overflow_hidden()
|
||||
.line_height(row_height)
|
||||
.text_size(px(14.))
|
||||
.text_color(text_color)
|
||||
// The cache style is computed from the `rows` prop: change `rows` and
|
||||
// the editor's cached bounds change, busting its cache and re-laying
|
||||
// out the text. (`Input` just uses `size_full()` — nothing to vary.)
|
||||
.child(
|
||||
editor.cached(
|
||||
StyleRefinement::default()
|
||||
.w_full()
|
||||
.h(row_height * self.rows as f32),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
173
crates/gpui/examples/view_example/view_example_main.rs
Normal file
173
crates/gpui/examples/view_example/view_example_main.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
#![cfg_attr(target_family = "wasm", no_main)]
|
||||
|
||||
//! View example — composing a text input from the `View` primitives.
|
||||
//!
|
||||
//! The whole point: a text input is deceptively complicated, and `View` makes it
|
||||
//! easy to compose one. Three pieces, each shown in its own section:
|
||||
//!
|
||||
//! * `Editor` — the workhorse entity: cursor, blink, focus, keyboard, and a
|
||||
//! specialized text renderer. All the hard parts live here.
|
||||
//! * `String` — the data plane. `editor.text(cx)` / `value.read(cx)` get it out.
|
||||
//! * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows
|
||||
//! the editor internally) OR an `Editor` (so you can read the cursor).
|
||||
//!
|
||||
//! Run: `cargo run -p gpui --example view_example`
|
||||
|
||||
mod example_editor;
|
||||
mod example_input;
|
||||
mod example_text_area;
|
||||
|
||||
#[cfg(test)]
|
||||
mod example_tests;
|
||||
|
||||
use example_editor::Editor;
|
||||
use example_input::Input;
|
||||
use example_text_area::TextArea;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window,
|
||||
WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
|
||||
actions!(
|
||||
view_example,
|
||||
[Backspace, Delete, Left, Right, Home, End, Enter, Quit]
|
||||
);
|
||||
|
||||
/// A tiny stateless view that reads an editor's cursor and is composed *beside*
|
||||
/// the thing editing it — two views over one entity, zero wiring.
|
||||
#[derive(IntoElement)]
|
||||
struct CursorReadout {
|
||||
editor: Entity<Editor>,
|
||||
}
|
||||
|
||||
impl CursorReadout {
|
||||
fn new(editor: Entity<Editor>) -> Self {
|
||||
Self { editor }
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::RenderOnce for CursorReadout {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let cursor = self.editor.read(cx).cursor;
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(hsla(0., 0., 0.45, 1.))
|
||||
.child(SharedString::from(format!("cursor @ {cursor}")))
|
||||
}
|
||||
}
|
||||
|
||||
struct ViewExample;
|
||||
|
||||
impl ViewExample {
|
||||
fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ViewExample {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// The data plane: plain strings, allocated at the top by the hook.
|
||||
let name = window.use_state(cx, |_, _| String::new());
|
||||
let email = window.use_state(cx, |_, _| String::from("me@example.com"));
|
||||
let bio = window.use_state(cx, |_, _| String::new());
|
||||
// Editors that own their own string internally — no extra wiring up top.
|
||||
let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx));
|
||||
let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx));
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.bg(rgb(0xf0f0f0))
|
||||
.p(px(24.))
|
||||
.gap(px(24.))
|
||||
.child(
|
||||
section("Inputs — from a String (cursor stays internal)")
|
||||
.child(Input::new(name).width(px(320.)))
|
||||
.child(
|
||||
Input::new(email)
|
||||
.width(px(320.))
|
||||
.color(hsla(0., 0., 0.3, 1.)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Input — from an Editor (read its cursor beside it)").child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(12.))
|
||||
.child(Input::editor(owned.clone()).width(px(320.)))
|
||||
.child(CursorReadout::new(owned)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Text areas — from a String, or from an Editor")
|
||||
.child(TextArea::new(bio, 3))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap(px(12.))
|
||||
.child(TextArea::editor(notes.clone(), 3).color(hsla(
|
||||
250. / 360.,
|
||||
0.7,
|
||||
0.4,
|
||||
1.,
|
||||
)))
|
||||
.child(CursorReadout::new(notes)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A labeled vertical section.
|
||||
fn section(title: &str) -> Div {
|
||||
div().flex().flex_col().gap(px(8.)).child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(hsla(0., 0., 0.3, 1.))
|
||||
.child(SharedString::from(title.to_string())),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_example() {
|
||||
application().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("backspace", Backspace, None),
|
||||
KeyBinding::new("delete", Delete, None),
|
||||
KeyBinding::new("left", Left, None),
|
||||
KeyBinding::new("right", Right, None),
|
||||
KeyBinding::new("home", Home, None),
|
||||
KeyBinding::new("end", End, None),
|
||||
KeyBinding::new("enter", Enter, None),
|
||||
KeyBinding::new("cmd-q", Quit, None),
|
||||
]);
|
||||
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| cx.new(|_| ViewExample::new()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
cx.on_action(|_: &Quit, cx| cx.quit());
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn main() {
|
||||
run_example();
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
gpui_platform::web_init();
|
||||
run_example();
|
||||
}
|
||||
|
|
@ -143,6 +143,31 @@ impl Drop for AppRefMut<'_> {
|
|||
/// You won't interact with this type much outside of initial configuration and startup.
|
||||
pub struct Application(Rc<AppCell>);
|
||||
|
||||
/// A strong handle to an [`Application`] started with [`Application::run_embedded`].
|
||||
///
|
||||
/// Dropping this handle releases the app, so an embedder must hold it for as long as the
|
||||
/// app should run. While held, it is the embedder's entry point back into GPUI each time
|
||||
/// the external run loop gives it control.
|
||||
pub struct ApplicationHandle {
|
||||
app: Rc<AppCell>,
|
||||
}
|
||||
|
||||
impl ApplicationHandle {
|
||||
/// Invoke `f` with the app context. Must not be called re-entrantly from code that
|
||||
/// is already inside an update; the app state is a `RefCell` and will panic on a
|
||||
/// double borrow.
|
||||
pub fn update<R>(&self, f: impl FnOnce(&mut App) -> R) -> R {
|
||||
let cx = &mut *self.app.borrow_mut();
|
||||
f(cx)
|
||||
}
|
||||
|
||||
/// An [`AsyncApp`] for use across await points. It holds the app weakly; keeping the
|
||||
/// app alive remains this handle's job.
|
||||
pub fn to_async(&self) -> AsyncApp {
|
||||
self.update(|cx| cx.to_async())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an application before it is fully launched. Once your app is
|
||||
/// configured, you'll start the app with `App::run`.
|
||||
impl Application {
|
||||
|
|
@ -209,6 +234,28 @@ impl Application {
|
|||
}));
|
||||
}
|
||||
|
||||
/// Start the application for an embedder that drives the run loop itself.
|
||||
///
|
||||
/// On ordinary platforms `Platform::run` blocks for the lifetime of the app, and the
|
||||
/// app state is kept alive by [`Application::run`]'s stack frame. Embedded platforms —
|
||||
/// where the run loop belongs to someone else, e.g. GPUI compiled into a Wasm guest,
|
||||
/// or a GPUI view hosted inside a foreign native application — implement
|
||||
/// `Platform::run` to invoke the launch callback and return immediately. This method
|
||||
/// supports that shape: it returns an [`ApplicationHandle`] that keeps the app alive
|
||||
/// and lets the embedder re-enter it whenever the external run loop yields control.
|
||||
pub fn run_embedded<F>(self, on_finish_launching: F) -> ApplicationHandle
|
||||
where
|
||||
F: 'static + FnOnce(&mut App),
|
||||
{
|
||||
let this = self.0.clone();
|
||||
let platform = self.0.borrow().platform.clone();
|
||||
platform.run(Box::new(move || {
|
||||
let cx = &mut *this.borrow_mut();
|
||||
on_finish_launching(cx);
|
||||
}));
|
||||
ApplicationHandle { app: self.0 }
|
||||
}
|
||||
|
||||
/// Register a handler to be invoked when the platform instructs the application
|
||||
/// to open one or more URLs.
|
||||
pub fn on_open_urls<F>(&self, mut callback: F) -> &Self
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@
|
|||
|
||||
use crate::{
|
||||
A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId,
|
||||
FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window,
|
||||
FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window,
|
||||
util::FluentBuilder, window::with_element_arena,
|
||||
};
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use std::{
|
||||
any::{Any, type_name},
|
||||
any::Any,
|
||||
fmt::{self, Debug, Display},
|
||||
mem, panic,
|
||||
sync::Arc,
|
||||
|
|
@ -208,116 +208,6 @@ pub trait ParentElement {
|
|||
}
|
||||
}
|
||||
|
||||
/// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro
|
||||
/// for [`RenderOnce`]
|
||||
#[doc(hidden)]
|
||||
pub struct Component<C: RenderOnce> {
|
||||
component: Option<C>,
|
||||
#[cfg(debug_assertions)]
|
||||
source: &'static core::panic::Location<'static>,
|
||||
}
|
||||
|
||||
impl<C: RenderOnce> Component<C> {
|
||||
/// Create a new component from the given RenderOnce type.
|
||||
#[track_caller]
|
||||
pub fn new(component: C) -> Self {
|
||||
Component {
|
||||
component: Some(component),
|
||||
#[cfg(debug_assertions)]
|
||||
source: core::panic::Location::caller(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepaint_component(
|
||||
(element, name): &mut (AnyElement, &'static str),
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.with_id(ElementId::Name(SharedString::new_static(name)), |window| {
|
||||
element.prepaint(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn paint_component(
|
||||
(element, name): &mut (AnyElement, &'static str),
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.with_id(ElementId::Name(SharedString::new_static(name)), |window| {
|
||||
element.paint(window, cx);
|
||||
})
|
||||
}
|
||||
impl<C: RenderOnce> Element for Component<C> {
|
||||
type RequestLayoutState = (AnyElement, &'static str);
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
#[cfg(debug_assertions)]
|
||||
return Some(self.source);
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
return None;
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
window.with_id(ElementId::Name(type_name::<C>().into()), |window| {
|
||||
let mut element = self
|
||||
.component
|
||||
.take()
|
||||
.unwrap()
|
||||
.render(window, cx)
|
||||
.into_any_element();
|
||||
|
||||
let layout_id = element.request_layout(window, cx);
|
||||
(layout_id, (element, type_name::<C>()))
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
state: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
prepaint_component(state, window, cx);
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
state: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
paint_component(state, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: RenderOnce> IntoElement for Component<C> {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A globally unique identifier for an element, used to track state across frames.
|
||||
#[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct GlobalElementId(pub(crate) Arc<[ElementId]>);
|
||||
|
|
|
|||
126
crates/gpui/src/elements/container_query.rs
Normal file
126
crates/gpui/src/elements/container_query.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//! A container query element, in the spirit of CSS container queries.
|
||||
//! The element's own size is determined solely by its style and the space
|
||||
//! offered by its parent.
|
||||
|
||||
use refineable::Refineable as _;
|
||||
|
||||
use crate::{
|
||||
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId,
|
||||
InspectorElementId, IntoElement, LayoutId, Pixels, Size, Style, StyleRefinement, Styled,
|
||||
Window, relative,
|
||||
};
|
||||
|
||||
/// Construct a container query element with the given render callback.
|
||||
/// The callback receives the size the element was assigned during layout and
|
||||
/// returns the contents to display within it.
|
||||
///
|
||||
/// By default the element fills its parent (equivalent to `.size_full()`);
|
||||
/// use the [`Styled`] methods to size it differently. Because the contents
|
||||
/// don't exist until after layout, they cannot influence the element's size.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use gpui::{container_query, div, px, IntoElement, ParentElement};
|
||||
/// container_query(|size, _window, _cx| {
|
||||
/// if size.width < px(240.) {
|
||||
/// div().child("Narrow layout")
|
||||
/// } else {
|
||||
/// div().child("Wide layout")
|
||||
/// }
|
||||
/// });
|
||||
/// ```
|
||||
pub fn container_query<E>(
|
||||
render: impl 'static + FnOnce(Size<Pixels>, &mut Window, &mut App) -> E,
|
||||
) -> ContainerQuery
|
||||
where
|
||||
E: IntoElement,
|
||||
{
|
||||
let mut base_style = StyleRefinement::default();
|
||||
base_style.size.width = Some(relative(1.).into());
|
||||
base_style.size.height = Some(relative(1.).into());
|
||||
|
||||
ContainerQuery {
|
||||
render: Some(Box::new(|size, window, cx| {
|
||||
render(size, window, cx).into_any_element()
|
||||
})),
|
||||
style: base_style,
|
||||
}
|
||||
}
|
||||
|
||||
/// A container query element, created with [`container_query`].
|
||||
pub struct ContainerQuery {
|
||||
render: Option<Box<dyn FnOnce(Size<Pixels>, &mut Window, &mut App) -> AnyElement>>,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl Element for ContainerQuery {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = Option<AnyElement>;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.refine(&self.style);
|
||||
let layout_id = window.request_layout(style, [], cx);
|
||||
(layout_id, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let render = self.render.take()?;
|
||||
let mut child = render(bounds.size, window, cx);
|
||||
child.layout_as_root(bounds.size.map(AvailableSpace::Definite), window, cx);
|
||||
child.prepaint_at(bounds.origin, window, cx);
|
||||
Some(child)
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Some(child) = prepaint {
|
||||
child.paint(window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for ContainerQuery {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ContainerQuery {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
|
@ -2860,7 +2860,6 @@ impl Interactivity {
|
|||
}
|
||||
|
||||
if let Some(hover_listener) = self.hover_listener.take() {
|
||||
let hitbox = hitbox.clone();
|
||||
let was_hovered = element_state
|
||||
.hover_listener_state
|
||||
.get_or_insert_with(Default::default)
|
||||
|
|
@ -2869,22 +2868,35 @@ impl Interactivity {
|
|||
.pending_mouse_down
|
||||
.get_or_insert_with(Default::default)
|
||||
.clone();
|
||||
|
||||
window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
let is_hovered = has_mouse_down.borrow().is_none()
|
||||
&& !cx.has_active_drag()
|
||||
&& hitbox.is_hovered(window);
|
||||
let hover_listener = Rc::new(hover_listener);
|
||||
let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| {
|
||||
let mut was_hovered = was_hovered.borrow_mut();
|
||||
|
||||
if is_hovered != *was_hovered {
|
||||
*was_hovered = is_hovered;
|
||||
drop(was_hovered);
|
||||
|
||||
hover_listener(&is_hovered, window, cx);
|
||||
}
|
||||
};
|
||||
|
||||
window.on_mouse_event({
|
||||
let update_hover = update_hover.clone();
|
||||
let hitbox = hitbox.clone();
|
||||
move |_: &MouseMoveEvent, phase, window, cx| {
|
||||
if phase == DispatchPhase::Bubble {
|
||||
let is_hovered = has_mouse_down.borrow().is_none()
|
||||
&& !cx.has_active_drag()
|
||||
&& hitbox.is_hovered(window);
|
||||
update_hover(is_hovered, window, cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The pointer can leave the window without a final MouseMove, so also
|
||||
// clear hover on MouseExited.
|
||||
window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| {
|
||||
if phase == DispatchPhase::Bubble {
|
||||
update_hover(false, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -338,6 +338,17 @@ impl ListState {
|
|||
self
|
||||
}
|
||||
|
||||
/// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb
|
||||
/// is correctly sized from the first frame, without measuring all items up front.
|
||||
///
|
||||
/// As items are actually rendered their real heights replace the hint, so the scrollbar
|
||||
/// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`]
|
||||
/// for lists where items have roughly uniform heights (e.g. table rows).
|
||||
pub fn with_uniform_item_height(self, height: Pixels) -> Self {
|
||||
self.apply_uniform_item_height(height);
|
||||
self
|
||||
}
|
||||
|
||||
/// Reset this instantiation of the list state.
|
||||
///
|
||||
/// Note that this will cause scroll events to be dropped until the next paint.
|
||||
|
|
@ -355,6 +366,33 @@ impl ListState {
|
|||
self.splice(0..old_count, element_count);
|
||||
}
|
||||
|
||||
/// Reset the list to `element_count` items, pre-populating every item with a
|
||||
/// uniform height hint so the scrollbar thumb is correctly sized from the first
|
||||
/// frame even for off-screen items.
|
||||
pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) {
|
||||
self.reset(element_count);
|
||||
self.apply_uniform_item_height(height);
|
||||
}
|
||||
|
||||
fn apply_uniform_item_height(&self, height: Pixels) {
|
||||
let size_hint = Size {
|
||||
width: px(0.),
|
||||
height,
|
||||
};
|
||||
let mut state = self.0.borrow_mut();
|
||||
let new_items = state
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| ListItem::Unmeasured {
|
||||
size_hint: Some(item.size_hint().unwrap_or(size_hint)),
|
||||
focus_handle: item.focus_handle(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut tree = SumTree::default();
|
||||
tree.extend(new_items, ());
|
||||
state.items = tree;
|
||||
}
|
||||
|
||||
/// Remeasure all items while preserving proportional scroll position.
|
||||
///
|
||||
/// Use this when item heights may have changed (e.g., font size changes)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod anchored;
|
||||
mod animation;
|
||||
mod canvas;
|
||||
mod container_query;
|
||||
mod deferred;
|
||||
mod div;
|
||||
mod image_cache;
|
||||
|
|
@ -14,6 +15,7 @@ mod uniform_list;
|
|||
pub use anchored::*;
|
||||
pub use animation::*;
|
||||
pub use canvas::*;
|
||||
pub use container_query::*;
|
||||
pub use deferred::*;
|
||||
pub use div::*;
|
||||
pub use image_cache::*;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue