Compare commits
40 Commits
fix-vim-se
...
ep-ollama
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3274a6abb0 | ||
|
|
e3ffc53c6a | ||
|
|
f5cafe5b95 | ||
|
|
2369fc91b4 | ||
|
|
cc2d1f935f | ||
|
|
e39dee27cb | ||
|
|
d3ebd02828 | ||
|
|
8062ee53a6 | ||
|
|
f9462da2f7 | ||
|
|
61dd6a8f31 | ||
|
|
abb199c85e | ||
|
|
cebbf77491 | ||
|
|
0180f3e72a | ||
|
|
5488a19221 | ||
|
|
bb1198e7d6 | ||
|
|
69fe27f45e | ||
|
|
469da2fd07 | ||
|
|
4f87822133 | ||
|
|
9a69d89f88 | ||
|
|
54f360ace1 | ||
|
|
b2a0b78ece | ||
|
|
f1ca2f9f31 | ||
|
|
4b34adedd2 | ||
|
|
df48294caa | ||
|
|
cdc5cc348f | ||
|
|
0f7f540138 | ||
|
|
184001b33b | ||
|
|
225a2a8a20 | ||
|
|
ea37057814 | ||
|
|
77cdef3596 | ||
|
|
05108c50fd | ||
|
|
07538ff08e | ||
|
|
9073a2666c | ||
|
|
843a35a1a9 | ||
|
|
aff93f2f6c | ||
|
|
0c9992c5e9 | ||
|
|
cec46079fe | ||
|
|
f9b69aeff0 | ||
|
|
f00cb371f4 | ||
|
|
25e1e2ecdd |
3
.github/workflows/extension_tests.yml
vendored
3
.github/workflows/extension_tests.yml
vendored
@@ -61,7 +61,8 @@ jobs:
|
||||
uses: namespacelabs/nscloud-cache-action@v1
|
||||
with:
|
||||
cache: rust
|
||||
- name: steps::cargo_fmt
|
||||
- id: cargo_fmt
|
||||
name: steps::cargo_fmt
|
||||
run: cargo fmt --all -- --check
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: extension_tests::run_clippy
|
||||
|
||||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@@ -26,7 +26,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -71,15 +72,15 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::trigger_autofix
|
||||
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
|
||||
- id: record_clippy_failure
|
||||
name: steps::record_clippy_failure
|
||||
if: always()
|
||||
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: steps::cargo_install_nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -93,6 +94,8 @@ jobs:
|
||||
run: |
|
||||
rm -rf ./../.cargo
|
||||
shell: bash -euxo pipefail {0}
|
||||
outputs:
|
||||
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
|
||||
timeout-minutes: 60
|
||||
run_tests_windows:
|
||||
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
|
||||
@@ -111,7 +114,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy.ps1
|
||||
shell: pwsh
|
||||
- name: steps::clear_target_dir_if_large
|
||||
|
||||
6
.github/workflows/release_nightly.yml
vendored
6
.github/workflows/release_nightly.yml
vendored
@@ -20,7 +20,8 @@ jobs:
|
||||
with:
|
||||
clean: false
|
||||
fetch-depth: 0
|
||||
- name: steps::cargo_fmt
|
||||
- id: cargo_fmt
|
||||
name: steps::cargo_fmt
|
||||
run: cargo fmt --all -- --check
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: ./script/clippy
|
||||
@@ -44,7 +45,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy.ps1
|
||||
shell: pwsh
|
||||
- name: steps::clear_target_dir_if_large
|
||||
|
||||
55
.github/workflows/run_tests.yml
vendored
55
.github/workflows/run_tests.yml
vendored
@@ -74,18 +74,19 @@ jobs:
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
with:
|
||||
version: '9'
|
||||
- name: ./script/prettier
|
||||
- id: prettier
|
||||
name: steps::prettier
|
||||
run: ./script/prettier
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::cargo_fmt
|
||||
- id: cargo_fmt
|
||||
name: steps::cargo_fmt
|
||||
run: cargo fmt --all -- --check
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::trigger_autofix
|
||||
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=false
|
||||
- id: record_style_failure
|
||||
name: steps::record_style_failure
|
||||
if: always()
|
||||
run: echo "failed=${{ steps.prettier.outcome == 'failure' || steps.cargo_fmt.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ./script/check-todos
|
||||
run: ./script/check-todos
|
||||
shell: bash -euxo pipefail {0}
|
||||
@@ -96,6 +97,8 @@ jobs:
|
||||
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06
|
||||
with:
|
||||
config: ./typos.toml
|
||||
outputs:
|
||||
style_failed: ${{ steps.record_style_failure.outputs.failed == 'true' }}
|
||||
timeout-minutes: 60
|
||||
run_tests_windows:
|
||||
needs:
|
||||
@@ -116,7 +119,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy.ps1
|
||||
shell: pwsh
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -163,15 +167,15 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::trigger_autofix
|
||||
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
|
||||
- id: record_clippy_failure
|
||||
name: steps::record_clippy_failure
|
||||
if: always()
|
||||
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: steps::cargo_install_nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -185,6 +189,8 @@ jobs:
|
||||
run: |
|
||||
rm -rf ./../.cargo
|
||||
shell: bash -euxo pipefail {0}
|
||||
outputs:
|
||||
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
|
||||
timeout-minutes: 60
|
||||
run_tests_mac:
|
||||
needs:
|
||||
@@ -205,7 +211,8 @@ jobs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: steps::clippy
|
||||
- id: clippy
|
||||
name: steps::clippy
|
||||
run: ./script/clippy
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::clear_target_dir_if_large
|
||||
@@ -585,6 +592,24 @@ jobs:
|
||||
|
||||
exit $EXIT_CODE
|
||||
shell: bash -euxo pipefail {0}
|
||||
call_autofix:
|
||||
needs:
|
||||
- check_style
|
||||
- run_tests_linux
|
||||
if: always() && (needs.check_style.outputs.style_failed == 'true' || needs.run_tests_linux.outputs.clippy_failed == 'true') && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
|
||||
runs-on: namespace-profile-2x4-ubuntu-2404
|
||||
steps:
|
||||
- id: get-app-token
|
||||
name: steps::authenticate_as_zippy
|
||||
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
|
||||
with:
|
||||
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
|
||||
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
|
||||
- name: run_tests::call_autofix::dispatch_autofix
|
||||
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=${{ needs.run_tests_linux.outputs.clippy_failed == 'true' }}
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -5320,6 +5320,7 @@ dependencies = [
|
||||
"client",
|
||||
"gpui",
|
||||
"language",
|
||||
"log",
|
||||
"text",
|
||||
]
|
||||
|
||||
@@ -5344,11 +5345,13 @@ dependencies = [
|
||||
"gpui",
|
||||
"indoc",
|
||||
"language",
|
||||
"language_model",
|
||||
"log",
|
||||
"lsp",
|
||||
"markdown",
|
||||
"menu",
|
||||
"multi_buffer",
|
||||
"ollama",
|
||||
"paths",
|
||||
"project",
|
||||
"regex",
|
||||
@@ -10880,12 +10883,19 @@ name = "ollama"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"edit_prediction_context",
|
||||
"edit_prediction_types",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"language",
|
||||
"language_model",
|
||||
"log",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -20697,6 +20707,7 @@ dependencies = [
|
||||
"nc",
|
||||
"node_runtime",
|
||||
"notifications",
|
||||
"ollama",
|
||||
"onboarding",
|
||||
"outline",
|
||||
"outline_panel",
|
||||
|
||||
@@ -1422,6 +1422,10 @@
|
||||
"model": "codestral-latest",
|
||||
"max_tokens": 150,
|
||||
},
|
||||
"ollama": {
|
||||
"api_url": "http://localhost:11434",
|
||||
"model": "qwen2.5-coder:3b-base",
|
||||
},
|
||||
// Whether edit predictions are enabled when editing text threads in the agent panel.
|
||||
// This setting has no effect if globally disabled.
|
||||
"enabled_in_text_threads": true,
|
||||
|
||||
@@ -192,6 +192,7 @@ pub struct ToolCall {
|
||||
pub locations: Vec<acp::ToolCallLocation>,
|
||||
pub resolved_locations: Vec<Option<AgentLocation>>,
|
||||
pub raw_input: Option<serde_json::Value>,
|
||||
pub raw_input_markdown: Option<Entity<Markdown>>,
|
||||
pub raw_output: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -222,6 +223,11 @@ impl ToolCall {
|
||||
}
|
||||
}
|
||||
|
||||
let raw_input_markdown = tool_call
|
||||
.raw_input
|
||||
.as_ref()
|
||||
.and_then(|input| markdown_for_raw_output(input, &language_registry, cx));
|
||||
|
||||
let result = Self {
|
||||
id: tool_call.tool_call_id,
|
||||
label: cx
|
||||
@@ -232,6 +238,7 @@ impl ToolCall {
|
||||
resolved_locations: Vec::default(),
|
||||
status,
|
||||
raw_input: tool_call.raw_input,
|
||||
raw_input_markdown,
|
||||
raw_output: tool_call.raw_output,
|
||||
};
|
||||
Ok(result)
|
||||
@@ -307,6 +314,7 @@ impl ToolCall {
|
||||
}
|
||||
|
||||
if let Some(raw_input) = raw_input {
|
||||
self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx);
|
||||
self.raw_input = Some(raw_input);
|
||||
}
|
||||
|
||||
@@ -1355,6 +1363,7 @@ impl AcpThread {
|
||||
locations: Vec::new(),
|
||||
resolved_locations: Vec::new(),
|
||||
raw_input: None,
|
||||
raw_input_markdown: None,
|
||||
raw_output: None,
|
||||
};
|
||||
self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx);
|
||||
|
||||
@@ -221,7 +221,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let favorites = if self.selector.supports_favorites() {
|
||||
Arc::new(AgentSettings::get_global(cx).favorite_model_ids())
|
||||
AgentSettings::get_global(cx).favorite_model_ids()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
@@ -242,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.delegate.filtered_entries =
|
||||
info_list_to_picker_entries(filtered_models, favorites);
|
||||
info_list_to_picker_entries(filtered_models, &favorites);
|
||||
// Finds the currently selected model in the list
|
||||
let new_index = this
|
||||
.delegate
|
||||
@@ -406,7 +406,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
|
||||
fn info_list_to_picker_entries(
|
||||
model_list: AgentModelList,
|
||||
favorites: Arc<HashSet<ModelId>>,
|
||||
favorites: &HashSet<ModelId>,
|
||||
) -> Vec<AcpModelPickerEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
@@ -572,13 +572,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_favorites(models: Vec<&str>) -> Arc<HashSet<ModelId>> {
|
||||
Arc::new(
|
||||
models
|
||||
.into_iter()
|
||||
.map(|m| ModelId::new(m.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
fn create_favorites(models: Vec<&str>) -> HashSet<ModelId> {
|
||||
models
|
||||
.into_iter()
|
||||
.map(|m| ModelId::new(m.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
|
||||
@@ -609,7 +607,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
@@ -625,7 +623,7 @@ mod tests {
|
||||
let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
|
||||
let favorites = create_favorites(vec![]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
@@ -641,7 +639,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
for entry in &entries {
|
||||
if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
|
||||
@@ -662,7 +660,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
let model_ids = get_entry_model_ids(&entries);
|
||||
|
||||
assert_eq!(model_ids[0], "zed/gemini");
|
||||
@@ -683,7 +681,7 @@ mod tests {
|
||||
|
||||
let favorites = create_favorites(vec!["zed/claude"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
let labels = get_entry_labels(&entries);
|
||||
|
||||
assert_eq!(
|
||||
@@ -723,7 +721,7 @@ mod tests {
|
||||
]);
|
||||
let favorites = create_favorites(vec!["zed/gemini"]);
|
||||
|
||||
let entries = info_list_to_picker_entries(models, favorites);
|
||||
let entries = info_list_to_picker_entries(models, &favorites);
|
||||
|
||||
assert!(matches!(
|
||||
entries.first(),
|
||||
|
||||
@@ -34,7 +34,7 @@ use language::Buffer;
|
||||
|
||||
use language_model::LanguageModelRegistry;
|
||||
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
|
||||
use project::{Project, ProjectEntryId};
|
||||
use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
|
||||
use prompt_store::{PromptId, PromptStore};
|
||||
use rope::Point;
|
||||
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
|
||||
@@ -260,6 +260,7 @@ impl ThreadFeedbackState {
|
||||
|
||||
pub struct AcpThreadView {
|
||||
agent: Rc<dyn AgentServer>,
|
||||
agent_server_store: Entity<AgentServerStore>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
thread_state: ThreadState,
|
||||
@@ -406,6 +407,7 @@ impl AcpThreadView {
|
||||
|
||||
Self {
|
||||
agent: agent.clone(),
|
||||
agent_server_store,
|
||||
workspace: workspace.clone(),
|
||||
project: project.clone(),
|
||||
entry_view_state,
|
||||
@@ -737,7 +739,7 @@ impl AcpThreadView {
|
||||
cx: &mut App,
|
||||
) {
|
||||
let agent_name = agent.name();
|
||||
let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id {
|
||||
let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
|
||||
let sub = window.subscribe(®istry, cx, {
|
||||
@@ -779,7 +781,6 @@ impl AcpThreadView {
|
||||
configuration_view,
|
||||
description: err
|
||||
.description
|
||||
.clone()
|
||||
.map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
|
||||
_subscription: subscription,
|
||||
};
|
||||
@@ -1088,10 +1089,7 @@ impl AcpThreadView {
|
||||
window.defer(cx, |window, cx| {
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired {
|
||||
description: None,
|
||||
provider_id: None,
|
||||
},
|
||||
AuthRequired::new(),
|
||||
agent,
|
||||
connection,
|
||||
window,
|
||||
@@ -1663,44 +1661,6 @@ impl AcpThreadView {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if method.0.as_ref() == "anthropic-api-key" {
|
||||
let registry = LanguageModelRegistry::global(cx);
|
||||
let provider = registry
|
||||
.read(cx)
|
||||
.provider(&language_model::ANTHROPIC_PROVIDER_ID)
|
||||
.unwrap();
|
||||
let this = cx.weak_entity();
|
||||
let agent = self.agent.clone();
|
||||
let connection = connection.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
if !provider.is_authenticated(cx) {
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired {
|
||||
description: Some("ANTHROPIC_API_KEY must be set".to_owned()),
|
||||
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
|
||||
},
|
||||
agent,
|
||||
connection,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
this.update(cx, |this, cx| {
|
||||
this.thread_state = Self::initial_state(
|
||||
agent,
|
||||
None,
|
||||
this.workspace.clone(),
|
||||
this.project.clone(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else if method.0.as_ref() == "vertex-ai"
|
||||
&& std::env::var("GOOGLE_API_KEY").is_err()
|
||||
&& (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
|
||||
@@ -2153,6 +2113,7 @@ impl AcpThreadView {
|
||||
chunks,
|
||||
indented: _,
|
||||
}) => {
|
||||
let mut is_blank = true;
|
||||
let is_last = entry_ix + 1 == total_entries;
|
||||
|
||||
let style = default_markdown_style(false, false, window, cx);
|
||||
@@ -2162,36 +2123,55 @@ impl AcpThreadView {
|
||||
.children(chunks.iter().enumerate().filter_map(
|
||||
|(chunk_ix, chunk)| match chunk {
|
||||
AssistantMessageChunk::Message { block } => {
|
||||
block.markdown().map(|md| {
|
||||
self.render_markdown(md.clone(), style.clone())
|
||||
.into_any_element()
|
||||
block.markdown().and_then(|md| {
|
||||
let this_is_blank = md.read(cx).source().trim().is_empty();
|
||||
is_blank = is_blank && this_is_blank;
|
||||
if this_is_blank {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
self.render_markdown(md.clone(), style.clone())
|
||||
.into_any_element(),
|
||||
)
|
||||
})
|
||||
}
|
||||
AssistantMessageChunk::Thought { block } => {
|
||||
block.markdown().map(|md| {
|
||||
self.render_thinking_block(
|
||||
entry_ix,
|
||||
chunk_ix,
|
||||
md.clone(),
|
||||
window,
|
||||
cx,
|
||||
block.markdown().and_then(|md| {
|
||||
let this_is_blank = md.read(cx).source().trim().is_empty();
|
||||
is_blank = is_blank && this_is_blank;
|
||||
if this_is_blank {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
self.render_thinking_block(
|
||||
entry_ix,
|
||||
chunk_ix,
|
||||
md.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
},
|
||||
))
|
||||
.into_any();
|
||||
|
||||
v_flex()
|
||||
.px_5()
|
||||
.py_1p5()
|
||||
.when(is_first_indented, |this| this.pt_0p5())
|
||||
.when(is_last, |this| this.pb_4())
|
||||
.w_full()
|
||||
.text_ui(cx)
|
||||
.child(message_body)
|
||||
.into_any()
|
||||
if is_blank {
|
||||
Empty.into_any()
|
||||
} else {
|
||||
v_flex()
|
||||
.px_5()
|
||||
.py_1p5()
|
||||
.when(is_last, |this| this.pb_4())
|
||||
.w_full()
|
||||
.text_ui(cx)
|
||||
.child(message_body)
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
AgentThreadEntry::ToolCall(tool_call) => {
|
||||
let has_terminals = tool_call.terminals().next().is_some();
|
||||
@@ -2223,7 +2203,7 @@ impl AcpThreadView {
|
||||
div()
|
||||
.relative()
|
||||
.w_full()
|
||||
.pl(rems_from_px(20.0))
|
||||
.pl_5()
|
||||
.bg(cx.theme().colors().panel_background.opacity(0.2))
|
||||
.child(
|
||||
div()
|
||||
@@ -2440,6 +2420,12 @@ impl AcpThreadView {
|
||||
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
|
||||
|
||||
let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
|
||||
let input_output_header = |label: SharedString| {
|
||||
Label::new(label)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx)
|
||||
};
|
||||
|
||||
let tool_output_display =
|
||||
if is_open {
|
||||
@@ -2481,7 +2467,25 @@ impl AcpThreadView {
|
||||
| ToolCallStatus::Completed
|
||||
| ToolCallStatus::Failed
|
||||
| ToolCallStatus::Canceled => v_flex()
|
||||
.w_full()
|
||||
.when(!is_edit && !is_terminal_tool, |this| {
|
||||
this.mt_1p5().w_full().child(
|
||||
v_flex()
|
||||
.ml(rems(0.4))
|
||||
.px_3p5()
|
||||
.pb_1()
|
||||
.gap_1()
|
||||
.border_l_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.child(input_output_header("Raw Input:".into()))
|
||||
.children(tool_call.raw_input_markdown.clone().map(|input| {
|
||||
self.render_markdown(
|
||||
input,
|
||||
default_markdown_style(false, false, window, cx),
|
||||
)
|
||||
}))
|
||||
.child(input_output_header("Output:".into())),
|
||||
)
|
||||
})
|
||||
.children(tool_call.content.iter().enumerate().map(
|
||||
|(content_ix, content)| {
|
||||
div().child(self.render_tool_call_content(
|
||||
@@ -2580,7 +2584,7 @@ impl AcpThreadView {
|
||||
.gap_px()
|
||||
.when(is_collapsible, |this| {
|
||||
this.child(
|
||||
Disclosure::new(("expand", entry_ix), is_open)
|
||||
Disclosure::new(("expand-output", entry_ix), is_open)
|
||||
.opened_icon(IconName::ChevronUp)
|
||||
.closed_icon(IconName::ChevronDown)
|
||||
.visible_on_hover(&card_header_id)
|
||||
@@ -2766,20 +2770,20 @@ impl AcpThreadView {
|
||||
let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
|
||||
|
||||
v_flex()
|
||||
.mt_1p5()
|
||||
.gap_2()
|
||||
.when(!card_layout, |this| {
|
||||
this.ml(rems(0.4))
|
||||
.px_3p5()
|
||||
.border_l_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
.when(card_layout, |this| {
|
||||
this.px_2().pb_2().when(context_ix > 0, |this| {
|
||||
this.border_t_1()
|
||||
.pt_2()
|
||||
.map(|this| {
|
||||
if card_layout {
|
||||
this.when(context_ix > 0, |this| {
|
||||
this.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
} else {
|
||||
this.ml(rems(0.4))
|
||||
.px_3p5()
|
||||
.border_l_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
}
|
||||
})
|
||||
.text_xs()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
@@ -3500,138 +3504,119 @@ impl AcpThreadView {
|
||||
pending_auth_method: Option<&acp::AuthMethodId>,
|
||||
window: &mut Window,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
let show_description =
|
||||
configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
|
||||
|
||||
) -> impl IntoElement {
|
||||
let auth_methods = connection.auth_methods();
|
||||
|
||||
v_flex().flex_1().size_full().justify_end().child(
|
||||
v_flex()
|
||||
.p_2()
|
||||
.pr_3()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().status().warning.opacity(0.04))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.color(Color::Warning)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
.child(Label::new("Authentication Required").size(LabelSize::Small)),
|
||||
)
|
||||
.children(description.map(|desc| {
|
||||
div().text_ui(cx).child(self.render_markdown(
|
||||
desc.clone(),
|
||||
default_markdown_style(false, false, window, cx),
|
||||
))
|
||||
}))
|
||||
.children(
|
||||
configuration_view
|
||||
.cloned()
|
||||
.map(|view| div().w_full().child(view)),
|
||||
)
|
||||
.when(show_description, |el| {
|
||||
el.child(
|
||||
Label::new(format!(
|
||||
"You are not currently authenticated with {}.{}",
|
||||
self.agent.name(),
|
||||
if auth_methods.len() > 1 {
|
||||
" Please choose one of the following options:"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.mb_1()
|
||||
.ml_5(),
|
||||
)
|
||||
})
|
||||
.when_some(pending_auth_method, |el, _| {
|
||||
el.child(
|
||||
h_flex()
|
||||
.py_4()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2),
|
||||
)
|
||||
.child(Label::new("Authenticating…").size(LabelSize::Small)),
|
||||
)
|
||||
})
|
||||
.when(!auth_methods.is_empty(), |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.when(!show_description, |this| {
|
||||
this.border_t_1()
|
||||
.mt_1()
|
||||
.pt_2()
|
||||
.border_color(cx.theme().colors().border.opacity(0.8))
|
||||
let agent_display_name = self
|
||||
.agent_server_store
|
||||
.read(cx)
|
||||
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
|
||||
.unwrap_or_else(|| self.agent.name());
|
||||
|
||||
let show_fallback_description = auth_methods.len() > 1
|
||||
&& configuration_view.is_none()
|
||||
&& description.is_none()
|
||||
&& pending_auth_method.is_none();
|
||||
|
||||
let auth_buttons = || {
|
||||
h_flex().justify_end().flex_wrap().gap_1().children(
|
||||
connection
|
||||
.auth_methods()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.map(|(ix, method)| {
|
||||
let (method_id, name) = if self.project.read(cx).is_via_remote_server()
|
||||
&& method.id.0.as_ref() == "oauth-personal"
|
||||
&& method.name == "Log in with Google"
|
||||
{
|
||||
("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
|
||||
} else {
|
||||
(method.id.0.clone(), method.name.clone())
|
||||
};
|
||||
|
||||
let agent_telemetry_id = connection.telemetry_id();
|
||||
|
||||
Button::new(method_id.clone(), name)
|
||||
.label_size(LabelSize::Small)
|
||||
.map(|this| {
|
||||
if ix == 0 {
|
||||
this.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
} else {
|
||||
this.style(ButtonStyle::Outlined)
|
||||
}
|
||||
})
|
||||
.children(connection.auth_methods().iter().enumerate().rev().map(
|
||||
|(ix, method)| {
|
||||
let (method_id, name) = if self
|
||||
.project
|
||||
.read(cx)
|
||||
.is_via_remote_server()
|
||||
&& method.id.0.as_ref() == "oauth-personal"
|
||||
&& method.name == "Log in with Google"
|
||||
{
|
||||
("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
|
||||
} else {
|
||||
(method.id.0.clone(), method.name.clone())
|
||||
};
|
||||
.when_some(method.description.clone(), |this, description| {
|
||||
this.tooltip(Tooltip::text(description))
|
||||
})
|
||||
.on_click({
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Started",
|
||||
agent = agent_telemetry_id,
|
||||
method = method_id
|
||||
);
|
||||
|
||||
let agent_telemetry_id = connection.telemetry_id();
|
||||
this.authenticate(
|
||||
acp::AuthMethodId::new(method_id.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
Button::new(method_id.clone(), name)
|
||||
.label_size(LabelSize::Small)
|
||||
.map(|this| {
|
||||
if ix == 0 {
|
||||
this.style(ButtonStyle::Tinted(TintColor::Warning))
|
||||
} else {
|
||||
this.style(ButtonStyle::Outlined)
|
||||
}
|
||||
})
|
||||
.when_some(
|
||||
method.description.clone(),
|
||||
|this, description| {
|
||||
this.tooltip(Tooltip::text(description))
|
||||
},
|
||||
)
|
||||
.on_click({
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
telemetry::event!(
|
||||
"Authenticate Agent Started",
|
||||
agent = agent_telemetry_id,
|
||||
method = method_id
|
||||
);
|
||||
if pending_auth_method.is_some() {
|
||||
return Callout::new()
|
||||
.icon(IconName::Info)
|
||||
.title(format!("Authenticating to {}…", agent_display_name))
|
||||
.actions_slot(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted)
|
||||
.with_rotate_animation(2)
|
||||
.into_any_element(),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
this.authenticate(
|
||||
acp::AuthMethodId::new(method_id.clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
Callout::new()
|
||||
.icon(IconName::Info)
|
||||
.title(format!("Authenticate to {}", agent_display_name))
|
||||
.when(auth_methods.len() == 1, |this| {
|
||||
this.actions_slot(auth_buttons())
|
||||
})
|
||||
.description_slot(
|
||||
v_flex()
|
||||
.text_ui(cx)
|
||||
.map(|this| {
|
||||
if show_fallback_description {
|
||||
this.child(
|
||||
Label::new("Choose one of the following authentication options:")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
} else {
|
||||
this.children(
|
||||
configuration_view
|
||||
.cloned()
|
||||
.map(|view| div().w_full().child(view)),
|
||||
)
|
||||
.children(description.map(|desc| {
|
||||
self.render_markdown(
|
||||
desc.clone(),
|
||||
default_markdown_style(false, false, window, cx),
|
||||
)
|
||||
}))
|
||||
}
|
||||
})
|
||||
.when(auth_methods.len() > 1, |this| {
|
||||
this.gap_1().child(auth_buttons())
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_load_error(
|
||||
@@ -5880,10 +5865,6 @@ impl AcpThreadView {
|
||||
};
|
||||
|
||||
let connection = thread.read(cx).connection().clone();
|
||||
let err = AuthRequired {
|
||||
description: None,
|
||||
provider_id: None,
|
||||
};
|
||||
this.clear_thread_error(cx);
|
||||
if let Some(message) = this.in_flight_prompt.take() {
|
||||
this.message_editor.update(cx, |editor, cx| {
|
||||
@@ -5892,7 +5873,14 @@ impl AcpThreadView {
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
window.defer(cx, |window, cx| {
|
||||
Self::handle_auth_required(this, err, agent, connection, window, cx);
|
||||
Self::handle_auth_required(
|
||||
this,
|
||||
AuthRequired::new(),
|
||||
agent,
|
||||
connection,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -5905,14 +5893,10 @@ impl AcpThreadView {
|
||||
};
|
||||
|
||||
let connection = thread.read(cx).connection().clone();
|
||||
let err = AuthRequired {
|
||||
description: None,
|
||||
provider_id: None,
|
||||
};
|
||||
self.clear_thread_error(cx);
|
||||
let this = cx.weak_entity();
|
||||
window.defer(cx, |window, cx| {
|
||||
Self::handle_auth_required(this, err, agent, connection, window, cx);
|
||||
Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6015,16 +5999,19 @@ impl Render for AcpThreadView {
|
||||
configuration_view,
|
||||
pending_auth_method,
|
||||
..
|
||||
} => self
|
||||
.render_auth_required_state(
|
||||
} => v_flex()
|
||||
.flex_1()
|
||||
.size_full()
|
||||
.justify_end()
|
||||
.child(self.render_auth_required_state(
|
||||
connection,
|
||||
description.as_ref(),
|
||||
configuration_view.as_ref(),
|
||||
pending_auth_method.as_ref(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any(),
|
||||
))
|
||||
.into_any_element(),
|
||||
ThreadState::Loading { .. } => v_flex()
|
||||
.flex_1()
|
||||
.child(self.render_recent_history(cx))
|
||||
|
||||
@@ -321,6 +321,7 @@ fn update_command_palette_filter(cx: &mut App) {
|
||||
}
|
||||
EditPredictionProvider::Zed
|
||||
| EditPredictionProvider::Codestral
|
||||
| EditPredictionProvider::Ollama
|
||||
| EditPredictionProvider::Experimental(_) => {
|
||||
filter.show_namespace("edit_prediction");
|
||||
filter.hide_namespace("copilot");
|
||||
|
||||
@@ -103,8 +103,9 @@ impl Model {
|
||||
|
||||
pub fn max_output_tokens(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Chat => Some(8_192),
|
||||
Self::Reasoner => Some(64_000),
|
||||
// Their API treats this max against the context window, which means we hit the limit a lot
|
||||
// Using the default value of None in the API instead
|
||||
Self::Chat | Self::Reasoner => None,
|
||||
Self::Custom {
|
||||
max_output_tokens, ..
|
||||
} => *max_output_tokens,
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures::AsyncReadExt as _;
|
||||
use gpui::{
|
||||
App, AppContext as _, Entity, SharedString, Task,
|
||||
App, AppContext as _, Entity, Global, SharedString, Task,
|
||||
http_client::{self, AsyncBody, Method},
|
||||
};
|
||||
use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
|
||||
@@ -300,14 +300,19 @@ pub const MERCURY_CREDENTIALS_URL: SharedString =
|
||||
SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
|
||||
pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
|
||||
pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
|
||||
pub static MERCURY_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
|
||||
|
||||
struct GlobalMercuryApiKey(Entity<ApiKeyState>);
|
||||
|
||||
impl Global for GlobalMercuryApiKey {}
|
||||
|
||||
pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
|
||||
MERCURY_API_KEY
|
||||
.get_or_init(|| {
|
||||
cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()))
|
||||
})
|
||||
.clone()
|
||||
if let Some(global) = cx.try_global::<GlobalMercuryApiKey>() {
|
||||
return global.0.clone();
|
||||
}
|
||||
let entity =
|
||||
cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()));
|
||||
cx.set_global(GlobalMercuryApiKey(entity.clone()));
|
||||
entity
|
||||
}
|
||||
|
||||
pub fn load_mercury_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use futures::AsyncReadExt as _;
|
||||
use gpui::{
|
||||
App, AppContext as _, Entity, SharedString, Task,
|
||||
App, AppContext as _, Entity, Global, SharedString, Task,
|
||||
http_client::{self, AsyncBody, Method},
|
||||
};
|
||||
use language::{Point, ToOffset as _};
|
||||
@@ -272,14 +272,19 @@ pub const SWEEP_CREDENTIALS_URL: SharedString =
|
||||
SharedString::new_static("https://autocomplete.sweep.dev");
|
||||
pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
|
||||
pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("SWEEP_AI_TOKEN");
|
||||
pub static SWEEP_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
|
||||
|
||||
struct GlobalSweepApiKey(Entity<ApiKeyState>);
|
||||
|
||||
impl Global for GlobalSweepApiKey {}
|
||||
|
||||
pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
|
||||
SWEEP_API_KEY
|
||||
.get_or_init(|| {
|
||||
cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()))
|
||||
})
|
||||
.clone()
|
||||
if let Some(global) = cx.try_global::<GlobalSweepApiKey>() {
|
||||
return global.0.clone();
|
||||
}
|
||||
let entity =
|
||||
cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()));
|
||||
cx.set_global(GlobalSweepApiKey(entity.clone()));
|
||||
entity
|
||||
}
|
||||
|
||||
pub fn load_sweep_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {
|
||||
|
||||
@@ -16,3 +16,4 @@ client.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
text.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -231,6 +231,10 @@ pub enum EditPredictionGranularity {
|
||||
}
|
||||
/// Returns edits updated based on user edits since the old snapshot. None is returned if any user
|
||||
/// edit is not a prefix of a predicted insertion.
|
||||
///
|
||||
/// This function is intentionally defensive: edit prediction providers may hold onto anchors from
|
||||
/// an older snapshot. Converting those anchors to offsets can panic if the buffer version no longer
|
||||
/// observes the anchor's timestamp. In that case, we treat the prediction as stale and return None.
|
||||
pub fn interpolate_edits(
|
||||
old_snapshot: &text::BufferSnapshot,
|
||||
new_snapshot: &text::BufferSnapshot,
|
||||
@@ -241,8 +245,12 @@ pub fn interpolate_edits(
|
||||
let mut model_edits = current_edits.iter().peekable();
|
||||
for user_edit in new_snapshot.edits_since::<usize>(&old_snapshot.version) {
|
||||
while let Some((model_old_range, _)) = model_edits.peek() {
|
||||
let model_old_range = model_old_range.to_offset(old_snapshot);
|
||||
if model_old_range.end < user_edit.old.start {
|
||||
let Some(model_old_offset_range) = safe_to_offset_range(old_snapshot, model_old_range)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if model_old_offset_range.end < user_edit.old.start {
|
||||
let (model_old_range, model_new_text) = model_edits.next().unwrap();
|
||||
edits.push((model_old_range.clone(), model_new_text.clone()));
|
||||
} else {
|
||||
@@ -251,7 +259,11 @@ pub fn interpolate_edits(
|
||||
}
|
||||
|
||||
if let Some((model_old_range, model_new_text)) = model_edits.peek() {
|
||||
let model_old_offset_range = model_old_range.to_offset(old_snapshot);
|
||||
let Some(model_old_offset_range) = safe_to_offset_range(old_snapshot, model_old_range)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if user_edit.old == model_old_offset_range {
|
||||
let user_new_text = new_snapshot
|
||||
.text_for_range(user_edit.new.clone())
|
||||
@@ -272,7 +284,38 @@ pub fn interpolate_edits(
|
||||
return None;
|
||||
}
|
||||
|
||||
// If any remaining edit ranges can't be converted safely, treat the prediction as stale.
|
||||
if model_edits
|
||||
.clone()
|
||||
.any(|(range, _)| safe_to_offset_range(old_snapshot, range).is_none())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
edits.extend(model_edits.cloned());
|
||||
|
||||
if edits.is_empty() { None } else { Some(edits) }
|
||||
}
|
||||
|
||||
fn safe_to_offset_range(
|
||||
snapshot: &text::BufferSnapshot,
|
||||
range: &Range<Anchor>,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
// Min/max anchors are always safe to convert.
|
||||
let start_ok = range.start.is_min()
|
||||
|| range.start.is_max()
|
||||
|| snapshot.version.observed(range.start.timestamp);
|
||||
let end_ok =
|
||||
range.end.is_min() || range.end.is_max() || snapshot.version.observed(range.end.timestamp);
|
||||
|
||||
if start_ok && end_ok {
|
||||
Some(range.to_offset(snapshot))
|
||||
} else {
|
||||
log::debug!(
|
||||
"Dropping stale edit prediction range because anchor timestamps are not observed by snapshot version (start_ok: {}, end_ok: {})",
|
||||
start_ok,
|
||||
end_ok
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@ futures.workspace = true
|
||||
gpui.workspace = true
|
||||
indoc.workspace = true
|
||||
language.workspace = true
|
||||
language_model.workspace = true
|
||||
markdown.workspace = true
|
||||
menu.workspace = true
|
||||
multi_buffer.workspace = true
|
||||
ollama.workspace = true
|
||||
paths.workspace = true
|
||||
project.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
@@ -22,6 +22,7 @@ use language::{
|
||||
EditPredictionsMode, File, Language,
|
||||
language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
|
||||
};
|
||||
use ollama::OllamaEditPredictionDelegate;
|
||||
use project::DisableAiSettings;
|
||||
use regex::Regex;
|
||||
use settings::{
|
||||
@@ -91,9 +92,9 @@ impl Render for EditPredictionButton {
|
||||
return div().hidden();
|
||||
}
|
||||
|
||||
let all_language_settings = all_language_settings(None, cx);
|
||||
let language_settings = all_language_settings(None, cx);
|
||||
|
||||
match all_language_settings.edit_predictions.provider {
|
||||
match language_settings.edit_predictions.provider {
|
||||
EditPredictionProvider::Copilot => {
|
||||
let Some(copilot) = Copilot::global(cx) else {
|
||||
return div().hidden();
|
||||
@@ -293,6 +294,60 @@ impl Render for EditPredictionButton {
|
||||
.with_handle(self.popover_menu_handle.clone()),
|
||||
)
|
||||
}
|
||||
EditPredictionProvider::Ollama => {
|
||||
let enabled = self.editor_enabled.unwrap_or(true);
|
||||
let this = cx.weak_entity();
|
||||
|
||||
div().child(
|
||||
PopoverMenu::new("ollama")
|
||||
.menu(move |window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.build_edit_prediction_context_menu(
|
||||
EditPredictionProvider::Ollama,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.anchor(Corner::BottomRight)
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("ollama-icon", IconName::AiOllama)
|
||||
.shape(IconButtonShape::Square)
|
||||
.when(!enabled, |this| {
|
||||
this.indicator(Indicator::dot().color(Color::Ignored))
|
||||
.indicator_border_color(Some(
|
||||
cx.theme().colors().status_bar_background,
|
||||
))
|
||||
}),
|
||||
move |_window, cx| {
|
||||
let settings = all_language_settings(None, cx);
|
||||
let tooltip_meta = match settings
|
||||
.edit_predictions
|
||||
.ollama
|
||||
.model
|
||||
.as_deref()
|
||||
{
|
||||
Some(model) if !model.trim().is_empty() => {
|
||||
format!("Powered by Ollama ({model})")
|
||||
}
|
||||
_ => {
|
||||
"Ollama model not configured — configure a model before use"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Tooltip::with_meta(
|
||||
"Edit Prediction",
|
||||
Some(&ToggleMenu),
|
||||
tooltip_meta,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)
|
||||
.with_handle(self.popover_menu_handle.clone()),
|
||||
)
|
||||
}
|
||||
provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => {
|
||||
let enabled = self.editor_enabled.unwrap_or(true);
|
||||
|
||||
@@ -547,6 +602,10 @@ impl EditPredictionButton {
|
||||
providers.push(EditPredictionProvider::Codestral);
|
||||
}
|
||||
|
||||
if OllamaEditPredictionDelegate::is_available(cx) {
|
||||
providers.push(EditPredictionProvider::Ollama);
|
||||
}
|
||||
|
||||
if cx.has_flag::<SweepFeatureFlag>()
|
||||
&& edit_prediction::sweep_ai::sweep_api_token(cx)
|
||||
.read(cx)
|
||||
@@ -595,6 +654,7 @@ impl EditPredictionButton {
|
||||
EditPredictionProvider::Copilot => "GitHub Copilot",
|
||||
EditPredictionProvider::Supermaven => "Supermaven",
|
||||
EditPredictionProvider::Codestral => "Codestral",
|
||||
EditPredictionProvider::Ollama => "Ollama",
|
||||
EditPredictionProvider::Experimental(
|
||||
EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
|
||||
) => "Sweep",
|
||||
|
||||
@@ -348,6 +348,61 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |language_settings| {
|
||||
language_settings.defaults.colorize_brackets = Some(true);
|
||||
});
|
||||
|
||||
let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor()));
|
||||
language_registry.add(markdown_lang());
|
||||
language_registry.add(rust_lang());
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| {
|
||||
buffer.set_language_registry(language_registry.clone());
|
||||
buffer.set_language(Some(markdown_lang()), cx);
|
||||
});
|
||||
|
||||
cx.set_state(indoc! {r#"
|
||||
fn main() {
|
||||
let v: Vec<Stringˇ> = vec![];
|
||||
}
|
||||
"#});
|
||||
cx.executor().advance_clock(Duration::from_millis(100));
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
r#"fn main«1()1» «1{
|
||||
let v: Vec<String> = vec!«2[]2»;
|
||||
}1»
|
||||
|
||||
1 hsla(207.80, 16.20%, 69.19%, 1.00)
|
||||
2 hsla(29.00, 54.00%, 65.88%, 1.00)
|
||||
"#,
|
||||
&bracket_colors_markup(&mut cx),
|
||||
"Markdown does not colorize <> brackets"
|
||||
);
|
||||
|
||||
cx.update_buffer(|buffer, cx| {
|
||||
buffer.set_language(Some(rust_lang()), cx);
|
||||
});
|
||||
cx.executor().advance_clock(Duration::from_millis(100));
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
r#"fn main«1()1» «1{
|
||||
let v: Vec«2<String>2» = vec!«2[]2»;
|
||||
}1»
|
||||
|
||||
1 hsla(207.80, 16.20%, 69.19%, 1.00)
|
||||
2 hsla(29.00, 54.00%, 65.88%, 1.00)
|
||||
"#,
|
||||
&bracket_colors_markup(&mut cx),
|
||||
"After switching to Rust, <> brackets are now colorized"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_bracket_colorization_when_editing(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |language_settings| {
|
||||
|
||||
@@ -331,7 +331,6 @@ static mut EXTENSION: Option<Box<dyn Extension>> = None;
|
||||
pub static ZED_API_VERSION: [u8; 6] = *include_bytes!(concat!(env!("OUT_DIR"), "/version_bytes"));
|
||||
|
||||
mod wit {
|
||||
|
||||
wit_bindgen::generate!({
|
||||
skip: ["init-extension"],
|
||||
path: "./wit/since_v0.8.0",
|
||||
@@ -524,6 +523,12 @@ impl wit::Guest for Component {
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
|
||||
pub struct LanguageServerId(String);
|
||||
|
||||
impl LanguageServerId {
|
||||
pub fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for LanguageServerId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
@@ -540,6 +545,12 @@ impl fmt::Display for LanguageServerId {
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
|
||||
pub struct ContextServerId(String);
|
||||
|
||||
impl ContextServerId {
|
||||
pub fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for ContextServerId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
|
||||
@@ -15,6 +15,7 @@ use askpass::AskPassDelegate;
|
||||
use cloud_llm_client::CompletionIntent;
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::RewrapOptions;
|
||||
use editor::{
|
||||
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
|
||||
actions::ExpandAllDiffHunks,
|
||||
@@ -2180,7 +2181,13 @@ impl GitPanel {
|
||||
let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
|
||||
let wrapped_message = editor.update(cx, |editor, cx| {
|
||||
editor.select_all(&Default::default(), window, cx);
|
||||
editor.rewrap(&Default::default(), window, cx);
|
||||
editor.rewrap_impl(
|
||||
RewrapOptions {
|
||||
override_language_settings: false,
|
||||
preserve_existing_whitespace: true,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
editor.text(cx)
|
||||
});
|
||||
if wrapped_message.trim().is_empty() {
|
||||
|
||||
@@ -566,22 +566,22 @@ impl Model {
|
||||
|
||||
pub fn max_token_count(&self) -> u64 {
|
||||
match self {
|
||||
Self::Gemini25FlashLite => 1_048_576,
|
||||
Self::Gemini25Flash => 1_048_576,
|
||||
Self::Gemini25Pro => 1_048_576,
|
||||
Self::Gemini3Pro => 1_048_576,
|
||||
Self::Gemini3Flash => 1_048_576,
|
||||
Self::Gemini25FlashLite
|
||||
| Self::Gemini25Flash
|
||||
| Self::Gemini25Pro
|
||||
| Self::Gemini3Pro
|
||||
| Self::Gemini3Flash => 1_048_576,
|
||||
Self::Custom { max_tokens, .. } => *max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_output_tokens(&self) -> Option<u64> {
|
||||
match self {
|
||||
Model::Gemini25FlashLite => Some(65_536),
|
||||
Model::Gemini25Flash => Some(65_536),
|
||||
Model::Gemini25Pro => Some(65_536),
|
||||
Model::Gemini3Pro => Some(65_536),
|
||||
Model::Gemini3Flash => Some(65_536),
|
||||
Model::Gemini25FlashLite
|
||||
| Model::Gemini25Flash
|
||||
| Model::Gemini25Pro
|
||||
| Model::Gemini3Pro
|
||||
| Model::Gemini3Flash => Some(65_536),
|
||||
Model::Custom { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,13 +1025,26 @@ impl PlatformWindow for WaylandWindow {
|
||||
fn resize(&mut self, size: Size<Pixels>) {
|
||||
let state = self.borrow();
|
||||
let state_ptr = self.0.clone();
|
||||
let dp_size = size.to_device_pixels(self.scale_factor());
|
||||
|
||||
// Keep window geometry consistent with configure handling. On Wayland, window geometry is
|
||||
// surface-local: resizing should not attempt to translate the window; the compositor
|
||||
// controls placement. We also account for client-side decoration insets and tiling.
|
||||
let window_geometry = inset_by_tiling(
|
||||
Bounds {
|
||||
origin: Point::default(),
|
||||
size,
|
||||
},
|
||||
state.inset(),
|
||||
state.tiling,
|
||||
)
|
||||
.map(|v| v.0 as i32)
|
||||
.map_size(|v| if v <= 0 { 1 } else { v });
|
||||
|
||||
state.surface_state.set_geometry(
|
||||
state.bounds.origin.x.0 as i32,
|
||||
state.bounds.origin.y.0 as i32,
|
||||
dp_size.width.0,
|
||||
dp_size.height.0,
|
||||
window_geometry.origin.x,
|
||||
window_geometry.origin.y,
|
||||
window_geometry.size.width,
|
||||
window_geometry.size.height,
|
||||
);
|
||||
|
||||
state
|
||||
|
||||
@@ -40,6 +40,11 @@ impl WindowsWindowInner {
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
let handled = match msg {
|
||||
// eagerly activate the window, so calls to `active_window` will work correctly
|
||||
WM_MOUSEACTIVATE => {
|
||||
unsafe { SetActiveWindow(handle).log_err() };
|
||||
None
|
||||
}
|
||||
WM_ACTIVATE => self.handle_activate_msg(wparam),
|
||||
WM_CREATE => self.handle_create_msg(handle),
|
||||
WM_MOVE => self.handle_move_msg(handle, lparam),
|
||||
|
||||
@@ -659,7 +659,7 @@ impl Platform for WindowsPlatform {
|
||||
if let Err(err) = result {
|
||||
// ERROR_NOT_FOUND means the credential doesn't exist.
|
||||
// Return Ok(None) to match macOS and Linux behavior.
|
||||
if err.code().0 == ERROR_NOT_FOUND.0 as i32 {
|
||||
if err.code() == ERROR_NOT_FOUND.to_hresult() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(err.into());
|
||||
|
||||
@@ -4966,7 +4966,7 @@ impl<V: 'static> From<WindowHandle<V>> for AnyWindowHandle {
|
||||
}
|
||||
|
||||
/// A handle to a window with any root view type, which can be downcast to a window with a specific root view type.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
||||
pub struct AnyWindowHandle {
|
||||
pub(crate) id: WindowId,
|
||||
state_type: TypeId,
|
||||
|
||||
@@ -1801,9 +1801,7 @@ impl Buffer {
|
||||
self.syntax_map.lock().did_parse(syntax_snapshot);
|
||||
self.request_autoindent(cx);
|
||||
self.parse_status.0.send(ParseStatus::Idle).unwrap();
|
||||
if self.text.version() != *self.tree_sitter_data.version() {
|
||||
self.invalidate_tree_sitter_data(self.text.snapshot());
|
||||
}
|
||||
self.invalidate_tree_sitter_data(self.text.snapshot());
|
||||
cx.emit(BufferEvent::Reparsed);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -385,6 +385,8 @@ pub struct EditPredictionSettings {
|
||||
pub copilot: CopilotSettings,
|
||||
/// Settings specific to Codestral.
|
||||
pub codestral: CodestralSettings,
|
||||
/// Settings specific to Ollama.
|
||||
pub ollama: OllamaSettings,
|
||||
/// Whether edit predictions are enabled in the assistant panel.
|
||||
/// This setting has no effect if globally disabled.
|
||||
pub enabled_in_text_threads: bool,
|
||||
@@ -430,6 +432,14 @@ pub struct CodestralSettings {
|
||||
pub api_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OllamaSettings {
|
||||
/// Model to use for completions.
|
||||
pub model: Option<String>,
|
||||
/// Custom API URL to use for Ollama.
|
||||
pub api_url: Option<String>,
|
||||
}
|
||||
|
||||
impl AllLanguageSettings {
|
||||
/// Returns the [`LanguageSettings`] for the language with the specified name.
|
||||
pub fn language<'a>(
|
||||
@@ -654,6 +664,12 @@ impl settings::Settings for AllLanguageSettings {
|
||||
api_url: codestral.api_url,
|
||||
};
|
||||
|
||||
let ollama = edit_predictions.ollama.unwrap();
|
||||
let ollama_settings = OllamaSettings {
|
||||
model: ollama.model,
|
||||
api_url: ollama.api_url,
|
||||
};
|
||||
|
||||
let enabled_in_text_threads = edit_predictions.enabled_in_text_threads.unwrap();
|
||||
|
||||
let mut file_types: FxHashMap<Arc<str>, (GlobSet, Vec<String>)> = FxHashMap::default();
|
||||
@@ -692,6 +708,7 @@ impl settings::Settings for AllLanguageSettings {
|
||||
mode: edit_predictions_mode,
|
||||
copilot: copilot_settings,
|
||||
codestral: codestral_settings,
|
||||
ollama: ollama_settings,
|
||||
enabled_in_text_threads,
|
||||
},
|
||||
defaults: default_language_settings,
|
||||
|
||||
@@ -295,6 +295,23 @@ impl LspInstaller for TyLspAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_if_user_installed(
|
||||
&self,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
_: Option<Toolchain>,
|
||||
_: &AsyncApp,
|
||||
) -> Option<LanguageServerBinary> {
|
||||
let Some(ty_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await else {
|
||||
return None;
|
||||
};
|
||||
let env = delegate.shell_env().await;
|
||||
Some(LanguageServerBinary {
|
||||
path: ty_bin,
|
||||
env: Some(env),
|
||||
arguments: vec!["server".into()],
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_server_binary(
|
||||
&self,
|
||||
latest_version: Self::BinaryVersion,
|
||||
|
||||
@@ -355,7 +355,7 @@ impl LspAdapter for RustLspAdapter {
|
||||
| lsp::CompletionTextEdit::Edit(lsp::TextEdit { new_text, .. }),
|
||||
) = completion.text_edit.as_ref()
|
||||
&& let Ok(mut snippet) = snippet::Snippet::parse(new_text)
|
||||
&& !snippet.tabstops.is_empty()
|
||||
&& snippet.tabstops.len() > 1
|
||||
{
|
||||
label = String::new();
|
||||
|
||||
@@ -421,7 +421,9 @@ impl LspAdapter for RustLspAdapter {
|
||||
0..label.rfind('(').unwrap_or(completion.label.len()),
|
||||
highlight_id,
|
||||
));
|
||||
} else if detail_left.is_none() {
|
||||
} else if detail_left.is_none()
|
||||
&& kind != Some(lsp::CompletionItemKind::SNIPPET)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1597,6 +1599,40 @@ mod tests {
|
||||
))
|
||||
);
|
||||
|
||||
// Postfix completion without actual tabstops (only implicit final $0)
|
||||
// The label should use completion.label so it can be filtered by "ref"
|
||||
let ref_completion = adapter
|
||||
.label_for_completion(
|
||||
&lsp::CompletionItem {
|
||||
kind: Some(lsp::CompletionItemKind::SNIPPET),
|
||||
label: "ref".to_string(),
|
||||
filter_text: Some("ref".to_string()),
|
||||
label_details: Some(CompletionItemLabelDetails {
|
||||
detail: None,
|
||||
description: Some("&expr".to_string()),
|
||||
}),
|
||||
detail: Some("&expr".to_string()),
|
||||
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
|
||||
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
||||
range: lsp::Range::default(),
|
||||
new_text: "&String::new()".to_string(),
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
&language,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ref_completion.is_some(),
|
||||
"ref postfix completion should have a label"
|
||||
);
|
||||
let ref_label = ref_completion.unwrap();
|
||||
let filter_text = &ref_label.text[ref_label.filter_range.clone()];
|
||||
assert!(
|
||||
filter_text.contains("ref"),
|
||||
"filter range text '{filter_text}' should contain 'ref' for filtering to work",
|
||||
);
|
||||
|
||||
// Test for correct range calculation with mixed empty and non-empty tabstops.(See https://github.com/zed-industries/zed/issues/44825)
|
||||
let res = adapter
|
||||
.label_for_completion(
|
||||
|
||||
@@ -155,15 +155,15 @@ impl Model {
|
||||
pub fn max_token_count(&self) -> u64 {
|
||||
match self {
|
||||
Self::CodestralLatest => 256000,
|
||||
Self::MistralLargeLatest => 131000,
|
||||
Self::MistralLargeLatest => 256000,
|
||||
Self::MistralMediumLatest => 128000,
|
||||
Self::MistralSmallLatest => 32000,
|
||||
Self::MagistralMediumLatest => 40000,
|
||||
Self::MagistralSmallLatest => 40000,
|
||||
Self::MagistralMediumLatest => 128000,
|
||||
Self::MagistralSmallLatest => 128000,
|
||||
Self::OpenMistralNemo => 131000,
|
||||
Self::OpenCodestralMamba => 256000,
|
||||
Self::DevstralMediumLatest => 128000,
|
||||
Self::DevstralSmallLatest => 262144,
|
||||
Self::DevstralMediumLatest => 256000,
|
||||
Self::DevstralSmallLatest => 256000,
|
||||
Self::Pixtral12BLatest => 128000,
|
||||
Self::PixtralLargeLatest => 128000,
|
||||
Self::Custom { max_tokens, .. } => *max_tokens,
|
||||
|
||||
@@ -17,9 +17,16 @@ schemars = ["dep:schemars"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
edit_prediction_context.workspace = true
|
||||
edit_prediction_types.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
http_client.workspace = true
|
||||
language.workspace = true
|
||||
language_model.workspace = true
|
||||
log.workspace = true
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
text.workspace = true
|
||||
@@ -1,4 +1,9 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
mod ollama_edit_prediction_delegate;
|
||||
|
||||
pub use ollama_edit_prediction_delegate::OllamaEditPredictionDelegate;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request as HttpRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
421
crates/ollama/src/ollama_edit_prediction_delegate.rs
Normal file
421
crates/ollama/src/ollama_edit_prediction_delegate.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions};
|
||||
use edit_prediction_types::{EditPrediction, EditPredictionDelegate};
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::{App, Context, Entity, Task};
|
||||
use http_client::HttpClient;
|
||||
use language::{
|
||||
Anchor, Buffer, BufferSnapshot, EditPreview, ToPoint, language_settings::all_language_settings,
|
||||
};
|
||||
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
ops::Range,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use text::ToOffset;
|
||||
|
||||
use crate::{OLLAMA_API_URL, get_models};
|
||||
|
||||
pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150);
|
||||
|
||||
const EXCERPT_OPTIONS: EditPredictionExcerptOptions = EditPredictionExcerptOptions {
|
||||
max_bytes: 1050,
|
||||
min_bytes: 525,
|
||||
target_before_cursor_over_total_bytes: 0.66,
|
||||
};
|
||||
|
||||
pub const RECOMMENDED_EDIT_PREDICTION_MODELS: [&str; 4] = [
|
||||
"qwen2.5-coder:3b-base",
|
||||
"qwen2.5-coder:3b",
|
||||
"qwen2.5-coder:7b-base",
|
||||
"qwen2.5-coder:7b",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CurrentCompletion {
|
||||
snapshot: BufferSnapshot,
|
||||
edits: Arc<[(Range<Anchor>, Arc<str>)]>,
|
||||
edit_preview: EditPreview,
|
||||
}
|
||||
|
||||
impl CurrentCompletion {
|
||||
fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
|
||||
edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OllamaEditPredictionDelegate {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
pending_request: Option<Task<Result<()>>>,
|
||||
current_completion: Option<CurrentCompletion>,
|
||||
}
|
||||
|
||||
impl OllamaEditPredictionDelegate {
|
||||
pub fn new(http_client: Arc<dyn HttpClient>) -> Self {
|
||||
Self {
|
||||
http_client,
|
||||
pending_request: None,
|
||||
current_completion: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_available(cx: &App) -> bool {
|
||||
let ollama_provider_id = LanguageModelProviderId::new("ollama");
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.provider(&ollama_provider_id)
|
||||
.is_some_and(|provider| provider.is_authenticated(cx))
|
||||
}
|
||||
|
||||
async fn fetch_completion(
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
prompt: String,
|
||||
suffix: String,
|
||||
model: String,
|
||||
api_url: String,
|
||||
) -> Result<String> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
log::debug!("Ollama: Requesting completion (model: {})", model);
|
||||
|
||||
let fim_prompt = format_fim_prompt(&model, &prompt, &suffix);
|
||||
|
||||
let request = OllamaGenerateRequest {
|
||||
model,
|
||||
prompt: fim_prompt,
|
||||
raw: true,
|
||||
stream: false,
|
||||
options: Some(OllamaGenerateOptions {
|
||||
num_predict: Some(64),
|
||||
temperature: Some(0.2),
|
||||
stop: Some(get_stop_tokens()),
|
||||
}),
|
||||
};
|
||||
|
||||
let request_body = serde_json::to_string(&request)?;
|
||||
|
||||
log::debug!("Ollama: Sending FIM request");
|
||||
|
||||
let http_request = http_client::Request::builder()
|
||||
.method(http_client::Method::POST)
|
||||
.uri(format!("{}/api/generate", api_url))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(http_client::AsyncBody::from(request_body))?;
|
||||
|
||||
let mut response = http_client.send(http_request).await?;
|
||||
let status = response.status();
|
||||
|
||||
log::debug!("Ollama: Response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
return Err(anyhow::anyhow!("Ollama API error: {} - {}", status, body));
|
||||
}
|
||||
|
||||
let mut body = String::new();
|
||||
response.body_mut().read_to_string(&mut body).await?;
|
||||
|
||||
let ollama_response: OllamaGenerateResponse =
|
||||
serde_json::from_str(&body).context("Failed to parse Ollama response")?;
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
log::debug!(
|
||||
"Ollama: Completion received ({:.2}s)",
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
|
||||
let completion = clean_completion(&ollama_response.response);
|
||||
Ok(completion)
|
||||
}
|
||||
}
|
||||
|
||||
impl EditPredictionDelegate for OllamaEditPredictionDelegate {
|
||||
fn name() -> &'static str {
|
||||
"ollama"
|
||||
}
|
||||
|
||||
fn display_name() -> &'static str {
|
||||
"Ollama"
|
||||
}
|
||||
|
||||
fn show_predictions_in_menu() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_enabled(&self, _buffer: &Entity<Buffer>, _cursor_position: Anchor, cx: &App) -> bool {
|
||||
Self::is_available(cx)
|
||||
}
|
||||
|
||||
fn is_refreshing(&self, _cx: &App) -> bool {
|
||||
self.pending_request.is_some()
|
||||
}
|
||||
|
||||
fn refresh(
|
||||
&mut self,
|
||||
buffer: Entity<Buffer>,
|
||||
cursor_position: Anchor,
|
||||
debounce: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::debug!("Ollama: Refresh called (debounce: {})", debounce);
|
||||
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
|
||||
if let Some(current_completion) = self.current_completion.as_ref() {
|
||||
if current_completion.interpolate(&snapshot).is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let http_client = self.http_client.clone();
|
||||
|
||||
let settings = all_language_settings(None, cx);
|
||||
let configured_model = settings.edit_predictions.ollama.model.clone();
|
||||
let api_url = settings
|
||||
.edit_predictions
|
||||
.ollama
|
||||
.api_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| OLLAMA_API_URL.to_string());
|
||||
|
||||
self.pending_request = Some(cx.spawn(async move |this, cx| {
|
||||
if debounce {
|
||||
log::debug!("Ollama: Debouncing for {:?}", DEBOUNCE_TIMEOUT);
|
||||
cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
|
||||
}
|
||||
|
||||
let model = if let Some(model) = configured_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
{
|
||||
model.to_string()
|
||||
} else {
|
||||
let local_models = get_models(http_client.as_ref(), &api_url, None).await?;
|
||||
let available_model_names = local_models.iter().map(|model| model.name.as_str());
|
||||
|
||||
match pick_recommended_edit_prediction_model(available_model_names) {
|
||||
Some(recommended) => recommended.to_string(),
|
||||
None => {
|
||||
log::debug!(
|
||||
"Ollama: No model configured and no recommended local model found; skipping edit prediction"
|
||||
);
|
||||
this.update(cx, |this, cx| {
|
||||
this.pending_request = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let cursor_offset = cursor_position.to_offset(&snapshot);
|
||||
let cursor_point = cursor_offset.to_point(&snapshot);
|
||||
let excerpt = EditPredictionExcerpt::select_from_buffer(
|
||||
cursor_point,
|
||||
&snapshot,
|
||||
&EXCERPT_OPTIONS,
|
||||
)
|
||||
.context("Line containing cursor doesn't fit in excerpt max bytes")?;
|
||||
|
||||
let excerpt_text = excerpt.text(&snapshot);
|
||||
let cursor_within_excerpt = cursor_offset
|
||||
.saturating_sub(excerpt.range.start)
|
||||
.min(excerpt_text.body.len());
|
||||
let prompt = excerpt_text.body[..cursor_within_excerpt].to_string();
|
||||
let suffix = excerpt_text.body[cursor_within_excerpt..].to_string();
|
||||
|
||||
let completion_text =
|
||||
match Self::fetch_completion(http_client, prompt, suffix, model, api_url).await {
|
||||
Ok(completion) => completion,
|
||||
Err(e) => {
|
||||
log::error!("Ollama: Failed to fetch completion: {}", e);
|
||||
this.update(cx, |this, cx| {
|
||||
this.pending_request = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if completion_text.trim().is_empty() {
|
||||
log::debug!("Ollama: Completion was empty after trimming; ignoring");
|
||||
this.update(cx, |this, cx| {
|
||||
this.pending_request = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let edits: Arc<[(Range<Anchor>, Arc<str>)]> = buffer.read_with(cx, |buffer, _cx| {
|
||||
// Use anchor_after (Right bias) so the cursor stays before the completion text,
|
||||
// not at the end of it. This matches how Copilot handles edit predictions.
|
||||
let position = buffer.anchor_after(cursor_offset);
|
||||
vec![(position..position, completion_text.into())].into()
|
||||
})?;
|
||||
let edit_preview = buffer
|
||||
.read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx))?
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.current_completion = Some(CurrentCompletion {
|
||||
snapshot,
|
||||
edits,
|
||||
edit_preview,
|
||||
});
|
||||
this.pending_request = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn accept(&mut self, _cx: &mut Context<Self>) {
|
||||
log::debug!("Ollama: Completion accepted");
|
||||
self.pending_request = None;
|
||||
self.current_completion = None;
|
||||
}
|
||||
|
||||
fn discard(&mut self, _cx: &mut Context<Self>) {
|
||||
log::debug!("Ollama: Completion discarded");
|
||||
self.pending_request = None;
|
||||
self.current_completion = None;
|
||||
}
|
||||
|
||||
fn suggest(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
_cursor_position: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<EditPrediction> {
|
||||
let current_completion = self.current_completion.as_ref()?;
|
||||
let buffer = buffer.read(cx);
|
||||
let edits = current_completion.interpolate(&buffer.snapshot())?;
|
||||
if edits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(EditPrediction::Local {
|
||||
id: None,
|
||||
edits,
|
||||
edit_preview: Some(current_completion.edit_preview.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_fim_prompt(model: &str, prefix: &str, suffix: &str) -> String {
|
||||
let model_base = model.split(':').next().unwrap_or(model);
|
||||
|
||||
match model_base {
|
||||
"codellama" | "code-llama" => {
|
||||
format!("<PRE> {prefix} <SUF>{suffix} <MID>")
|
||||
}
|
||||
"starcoder" | "starcoder2" | "starcoderbase" => {
|
||||
format!("<fim_prefix>{prefix}<fim_suffix>{suffix}<fim_middle>")
|
||||
}
|
||||
"deepseek-coder" | "deepseek-coder-v2" => {
|
||||
// DeepSeek uses special Unicode characters for FIM tokens
|
||||
format!("<|fim▁begin|>{prefix}<|fim▁hole|>{suffix}<|fim▁end|>")
|
||||
}
|
||||
"qwen2.5-coder" | "qwen-coder" | "qwen" => {
|
||||
format!("<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>")
|
||||
}
|
||||
"codegemma" => {
|
||||
format!("<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>")
|
||||
}
|
||||
"codestral" | "mistral" => {
|
||||
format!("[SUFFIX]{suffix}[PREFIX]{prefix}")
|
||||
}
|
||||
"glm" | "glm-4" | "glm-4.5" => {
|
||||
format!("<|code_prefix|>{prefix}<|code_suffix|>{suffix}<|code_middle|>")
|
||||
}
|
||||
_ => {
|
||||
format!("<fim_prefix>{prefix}<fim_suffix>{suffix}<fim_middle>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_stop_tokens() -> Vec<String> {
|
||||
vec![
|
||||
"<|endoftext|>".to_string(),
|
||||
"<|file_separator|>".to_string(),
|
||||
"<|fim_pad|>".to_string(),
|
||||
"<|fim_prefix|>".to_string(),
|
||||
"<|fim_middle|>".to_string(),
|
||||
"<|fim_suffix|>".to_string(),
|
||||
"<fim_prefix>".to_string(),
|
||||
"<fim_middle>".to_string(),
|
||||
"<fim_suffix>".to_string(),
|
||||
"<PRE>".to_string(),
|
||||
"<SUF>".to_string(),
|
||||
"<MID>".to_string(),
|
||||
"[PREFIX]".to_string(),
|
||||
"[SUFFIX]".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn clean_completion(response: &str) -> String {
|
||||
let mut result = response.to_string();
|
||||
|
||||
let end_tokens = [
|
||||
"<|endoftext|>",
|
||||
"<|file_separator|>",
|
||||
"<|fim_pad|>",
|
||||
"<|fim_prefix|>",
|
||||
"<|fim_middle|>",
|
||||
"<|fim_suffix|>",
|
||||
"<fim_prefix>",
|
||||
"<fim_middle>",
|
||||
"<fim_suffix>",
|
||||
"<PRE>",
|
||||
"<SUF>",
|
||||
"<MID>",
|
||||
"[PREFIX]",
|
||||
"[SUFFIX]",
|
||||
];
|
||||
|
||||
for token in &end_tokens {
|
||||
if let Some(pos) = result.find(token) {
|
||||
result.truncate(pos);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaGenerateRequest {
|
||||
model: String,
|
||||
prompt: String,
|
||||
raw: bool,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
options: Option<OllamaGenerateOptions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaGenerateOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
num_predict: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaGenerateResponse {
|
||||
response: String,
|
||||
}
|
||||
pub fn pick_recommended_edit_prediction_model<'a>(
|
||||
available_models: impl IntoIterator<Item = &'a str>,
|
||||
) -> Option<&'static str> {
|
||||
let available: std::collections::HashSet<&str> = available_models.into_iter().collect();
|
||||
|
||||
RECOMMENDED_EDIT_PREDICTION_MODELS
|
||||
.into_iter()
|
||||
.find(|recommended| available.contains(recommended))
|
||||
}
|
||||
@@ -460,7 +460,7 @@ impl AgentServerStore {
|
||||
.gemini
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.ignore_system_version)
|
||||
.unwrap_or(false),
|
||||
.unwrap_or(true),
|
||||
}),
|
||||
);
|
||||
self.external_agents.insert(
|
||||
|
||||
@@ -1672,59 +1672,6 @@ impl GitStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_entries_pending_by_project_paths(
|
||||
&mut self,
|
||||
project_paths: &[ProjectPath],
|
||||
stage: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let buffer_store = &self.buffer_store;
|
||||
|
||||
for project_path in project_paths {
|
||||
let Some(buffer) = buffer_store.read(cx).get_by_path(project_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
let Some(diff_state) = self.diffs.get(&buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
diff_state.update(cx, |diff_state, cx| {
|
||||
let Some(uncommitted_diff) = diff_state.uncommitted_diff() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let buffer_snapshot = buffer.read(cx).text_snapshot();
|
||||
let file_exists = buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.is_some_and(|file| file.disk_state().exists());
|
||||
|
||||
let all_hunks: Vec<_> = uncommitted_diff
|
||||
.read(cx)
|
||||
.hunks_intersecting_range(
|
||||
text::Anchor::MIN..text::Anchor::MAX,
|
||||
&buffer_snapshot,
|
||||
cx,
|
||||
)
|
||||
.collect();
|
||||
|
||||
if !all_hunks.is_empty() {
|
||||
uncommitted_diff.update(cx, |diff, cx| {
|
||||
diff.stage_or_unstage_hunks(
|
||||
stage,
|
||||
&all_hunks,
|
||||
&buffer_snapshot,
|
||||
file_exists,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_clone(
|
||||
&self,
|
||||
repo: String,
|
||||
@@ -4253,28 +4200,6 @@ impl Repository {
|
||||
save_futures
|
||||
}
|
||||
|
||||
fn mark_entries_pending_for_stage(
|
||||
&self,
|
||||
entries: &[RepoPath],
|
||||
stage: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(git_store) = self.git_store() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut project_paths = Vec::new();
|
||||
for repo_path in entries {
|
||||
if let Some(project_path) = self.repo_path_to_project_path(repo_path, cx) {
|
||||
project_paths.push(project_path);
|
||||
}
|
||||
}
|
||||
|
||||
git_store.update(cx, move |git_store, cx| {
|
||||
git_store.mark_entries_pending_by_project_paths(&project_paths, stage, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn stage_entries(
|
||||
&mut self,
|
||||
entries: Vec<RepoPath>,
|
||||
@@ -4283,9 +4208,6 @@ impl Repository {
|
||||
if entries.is_empty() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
self.mark_entries_pending_for_stage(&entries, true, cx);
|
||||
|
||||
let id = self.id;
|
||||
let save_tasks = self.save_buffers(&entries, cx);
|
||||
let paths = entries
|
||||
@@ -4351,9 +4273,6 @@ impl Repository {
|
||||
if entries.is_empty() {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
self.mark_entries_pending_for_stage(&entries, false, cx);
|
||||
|
||||
let id = self.id;
|
||||
let save_tasks = self.save_buffers(&entries, cx);
|
||||
let paths = entries
|
||||
|
||||
@@ -76,6 +76,7 @@ pub enum EditPredictionProvider {
|
||||
Supermaven,
|
||||
Zed,
|
||||
Codestral,
|
||||
Ollama,
|
||||
Experimental(&'static str),
|
||||
}
|
||||
|
||||
@@ -96,6 +97,7 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
|
||||
Supermaven,
|
||||
Zed,
|
||||
Codestral,
|
||||
Ollama,
|
||||
Experimental(String),
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
|
||||
Content::Supermaven => EditPredictionProvider::Supermaven,
|
||||
Content::Zed => EditPredictionProvider::Zed,
|
||||
Content::Codestral => EditPredictionProvider::Codestral,
|
||||
Content::Ollama => EditPredictionProvider::Ollama,
|
||||
Content::Experimental(name)
|
||||
if name == EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME =>
|
||||
{
|
||||
@@ -144,6 +147,7 @@ impl EditPredictionProvider {
|
||||
| EditPredictionProvider::Copilot
|
||||
| EditPredictionProvider::Supermaven
|
||||
| EditPredictionProvider::Codestral
|
||||
| EditPredictionProvider::Ollama
|
||||
| EditPredictionProvider::Experimental(_) => false,
|
||||
}
|
||||
}
|
||||
@@ -164,6 +168,8 @@ pub struct EditPredictionSettingsContent {
|
||||
pub copilot: Option<CopilotSettingsContent>,
|
||||
/// Settings specific to Codestral.
|
||||
pub codestral: Option<CodestralSettingsContent>,
|
||||
/// Settings specific to Ollama.
|
||||
pub ollama: Option<OllamaEditPredictionSettingsContent>,
|
||||
/// Whether edit predictions are enabled in the assistant prompt editor.
|
||||
/// This has no effect if globally disabled.
|
||||
pub enabled_in_text_threads: Option<bool>,
|
||||
@@ -203,6 +209,19 @@ pub struct CodestralSettingsContent {
|
||||
pub api_url: Option<String>,
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
|
||||
pub struct OllamaEditPredictionSettingsContent {
|
||||
/// Model to use for completions.
|
||||
///
|
||||
/// Default: none
|
||||
pub model: Option<String>,
|
||||
/// Api URL to use for completions.
|
||||
///
|
||||
/// Default: "http://localhost:11434"
|
||||
pub api_url: Option<String>,
|
||||
}
|
||||
|
||||
/// The mode in which edit predictions should be displayed.
|
||||
#[derive(
|
||||
Copy,
|
||||
|
||||
@@ -8,6 +8,9 @@ use gpui::{Entity, ScrollHandle, prelude::*};
|
||||
use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key};
|
||||
use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*};
|
||||
|
||||
const OLLAMA_API_URL_PLACEHOLDER: &str = "http://localhost:11434";
|
||||
const OLLAMA_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base";
|
||||
|
||||
use crate::{
|
||||
SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
|
||||
components::{SettingsInputField, SettingsSectionHeader},
|
||||
@@ -82,6 +85,7 @@ impl Render for EditPredictionSetupPage {
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(render_ollama_provider(settings_window.clone(), window, cx).into_any_element()),
|
||||
];
|
||||
|
||||
div()
|
||||
@@ -236,6 +240,107 @@ fn render_api_key_provider(
|
||||
})
|
||||
}
|
||||
|
||||
fn render_ollama_provider(
|
||||
settings_window: Entity<SettingsWindow>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<EditPredictionSetupPage>,
|
||||
) -> impl IntoElement {
|
||||
let ollama_settings = ollama_settings();
|
||||
let additional_fields = settings_window.update(cx, |settings_window, cx| {
|
||||
settings_window
|
||||
.render_sub_page_items_section(ollama_settings.iter().enumerate(), None, window, cx)
|
||||
.into_any_element()
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.id("ollama")
|
||||
.min_w_0()
|
||||
.pt_8()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
SettingsSectionHeader::new("Ollama")
|
||||
.icon(IconName::ZedPredict)
|
||||
.no_padding(true),
|
||||
)
|
||||
.child(
|
||||
Label::new("Configure the local Ollama server and model used for edit predictions.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(additional_fields)
|
||||
}
|
||||
|
||||
fn ollama_settings() -> Box<[SettingsPageItem]> {
|
||||
Box::new([
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "API URL",
|
||||
description: "The base URL of your Ollama server.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.ollama
|
||||
.as_ref()?
|
||||
.api_url
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.ollama
|
||||
.get_or_insert_default()
|
||||
.api_url = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.ollama.api_url"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Model",
|
||||
description: "The Ollama model to use for edit predictions.",
|
||||
field: Box::new(SettingField {
|
||||
pick: |settings| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.as_ref()?
|
||||
.ollama
|
||||
.as_ref()?
|
||||
.model
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings, value| {
|
||||
settings
|
||||
.project
|
||||
.all_languages
|
||||
.edit_predictions
|
||||
.get_or_insert_default()
|
||||
.ollama
|
||||
.get_or_insert_default()
|
||||
.model = value;
|
||||
},
|
||||
json_path: Some("edit_predictions.ollama.model"),
|
||||
}),
|
||||
metadata: Some(Box::new(SettingsFieldMetadata {
|
||||
placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
|
||||
..Default::default()
|
||||
})),
|
||||
files: USER,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
fn codestral_settings() -> Box<[SettingsPageItem]> {
|
||||
Box::new([
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
|
||||
@@ -790,8 +790,7 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
let focus = pane.has_focus(window, cx)
|
||||
|| matches!(reveal_strategy, RevealStrategy::Always);
|
||||
let focus = matches!(reveal_strategy, RevealStrategy::Always);
|
||||
pane.add_item(terminal_view, true, focus, None, window, cx);
|
||||
});
|
||||
|
||||
@@ -853,8 +852,7 @@ impl TerminalPanel {
|
||||
}
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
let focus = pane.has_focus(window, cx)
|
||||
|| matches!(reveal_strategy, RevealStrategy::Always);
|
||||
let focus = matches!(reveal_strategy, RevealStrategy::Always);
|
||||
pane.add_item(terminal_view, true, focus, None, window, cx);
|
||||
});
|
||||
|
||||
@@ -1171,64 +1169,67 @@ pub fn new_terminal_pane(
|
||||
let source = tab.pane.clone();
|
||||
let item_id_to_move = item.item_id();
|
||||
|
||||
let Ok(new_split_pane) = pane
|
||||
.drag_split_direction()
|
||||
.map(|split_direction| {
|
||||
drop_closure_terminal_panel.update(cx, |terminal_panel, cx| {
|
||||
let is_zoomed = if terminal_panel.active_pane == this_pane {
|
||||
pane.is_zoomed()
|
||||
} else {
|
||||
terminal_panel.active_pane.read(cx).is_zoomed()
|
||||
};
|
||||
let new_pane = new_terminal_pane(
|
||||
workspace.clone(),
|
||||
project.clone(),
|
||||
is_zoomed,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
|
||||
terminal_panel.center.split(
|
||||
&this_pane,
|
||||
&new_pane,
|
||||
split_direction,
|
||||
cx,
|
||||
)?;
|
||||
anyhow::Ok(new_pane)
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
else {
|
||||
return ControlFlow::Break(());
|
||||
// If no split direction, let the regular pane drop handler take care of it
|
||||
let Some(split_direction) = pane.drag_split_direction() else {
|
||||
return ControlFlow::Continue(());
|
||||
};
|
||||
|
||||
match new_split_pane.transpose() {
|
||||
// Source pane may be the one currently updated, so defer the move.
|
||||
Ok(Some(new_pane)) => cx
|
||||
.spawn_in(window, async move |_, cx| {
|
||||
cx.update(|window, cx| {
|
||||
move_item(
|
||||
&source,
|
||||
&new_pane,
|
||||
item_id_to_move,
|
||||
new_pane.read(cx).active_item_index(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
// Gather data synchronously before deferring
|
||||
let is_zoomed = drop_closure_terminal_panel
|
||||
.upgrade()
|
||||
.map(|terminal_panel| {
|
||||
let terminal_panel = terminal_panel.read(cx);
|
||||
if terminal_panel.active_pane == this_pane {
|
||||
pane.is_zoomed()
|
||||
} else {
|
||||
terminal_panel.active_pane.read(cx).is_zoomed()
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let workspace = workspace.clone();
|
||||
let terminal_panel = drop_closure_terminal_panel.clone();
|
||||
|
||||
// Defer the split operation to avoid re-entrancy panic.
|
||||
// The pane may be the one currently being updated, so we cannot
|
||||
// call mark_positions (via split) synchronously.
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
cx.update(|window, cx| {
|
||||
let Ok(new_pane) =
|
||||
terminal_panel.update(cx, |terminal_panel, cx| {
|
||||
let new_pane = new_terminal_pane(
|
||||
workspace, project, is_zoomed, window, cx,
|
||||
);
|
||||
terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
|
||||
terminal_panel.center.split(
|
||||
&this_pane,
|
||||
&new_pane,
|
||||
split_direction,
|
||||
cx,
|
||||
)?;
|
||||
anyhow::Ok(new_pane)
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach(),
|
||||
// If we drop into existing pane or current pane,
|
||||
// regular pane drop handler will take care of it,
|
||||
// using the right tab index for the operation.
|
||||
Ok(None) => return ControlFlow::Continue(()),
|
||||
err @ Err(_) => {
|
||||
err.log_err();
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
};
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(new_pane) = new_pane.log_err() else {
|
||||
return;
|
||||
};
|
||||
|
||||
move_item(
|
||||
&source,
|
||||
&new_pane,
|
||||
item_id_to_move,
|
||||
new_pane.read(cx).active_item_index(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
} else if let Some(project_path) = item.project_path(cx)
|
||||
&& let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx)
|
||||
{
|
||||
|
||||
@@ -8,8 +8,8 @@ mod terminal_slash_command;
|
||||
use assistant_slash_command::SlashCommandRegistry;
|
||||
use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
|
||||
Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
|
||||
ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
|
||||
};
|
||||
use persistence::TERMINAL_DB;
|
||||
@@ -687,10 +687,30 @@ impl TerminalView {
|
||||
|
||||
///Attempt to paste the clipboard into the terminal
|
||||
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||
self.terminal
|
||||
.update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
|
||||
let Some(clipboard) = cx.read_from_clipboard() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if clipboard.entries().iter().any(|entry| match entry {
|
||||
ClipboardEntry::Image(image) => !image.bytes.is_empty(),
|
||||
_ => false,
|
||||
}) {
|
||||
self.forward_ctrl_v(cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(text) = clipboard.text() {
|
||||
self.terminal
|
||||
.update(cx, |terminal, _cx| terminal.paste(&text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
|
||||
/// and attach images using their native workflows.
|
||||
fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
|
||||
self.terminal.update(cx, |term, _| {
|
||||
term.input(vec![0x16]);
|
||||
});
|
||||
}
|
||||
|
||||
fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
|
||||
|
||||
@@ -121,7 +121,7 @@ impl RenderOnce for Callout {
|
||||
Severity::Info => (
|
||||
IconName::Info,
|
||||
Color::Muted,
|
||||
cx.theme().colors().panel_background.opacity(0.),
|
||||
cx.theme().status().info_background.opacity(0.1),
|
||||
),
|
||||
Severity::Success => (
|
||||
IconName::Check,
|
||||
|
||||
@@ -886,8 +886,12 @@ impl<T: Item> ItemHandle for Entity<T> {
|
||||
// Only trigger autosave if focus has truly left the item.
|
||||
// If focus is still within the item's hierarchy (e.g., moved to a context menu),
|
||||
// don't trigger autosave to avoid unwanted formatting and cursor jumps.
|
||||
// Also skip autosave if focus moved to a modal (e.g., command palette),
|
||||
// since the user is still interacting with the workspace.
|
||||
let focus_handle = item.item_focus_handle(cx);
|
||||
if !focus_handle.contains_focused(window, cx) {
|
||||
if !focus_handle.contains_focused(window, cx)
|
||||
&& !workspace.has_active_modal(window, cx)
|
||||
{
|
||||
Pane::autosave_item(&item, workspace.project.clone(), window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,12 @@ impl Render for ModalLayer {
|
||||
background.fade_out(0.2);
|
||||
this.bg(background)
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.hide_modal(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.h(px(0.0))
|
||||
|
||||
@@ -3296,4 +3296,53 @@ mod tests {
|
||||
|
||||
assert_eq!(workspace.center_group, new_workspace.center_group);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_empty_workspace_window_bounds() {
|
||||
zlog::init_test();
|
||||
|
||||
let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
|
||||
let id = db.next_id().await.unwrap();
|
||||
|
||||
// Create a workspace with empty paths (empty workspace)
|
||||
let empty_paths: &[&str] = &[];
|
||||
let display_uuid = Uuid::new_v4();
|
||||
let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
|
||||
origin: point(px(100.0), px(200.0)),
|
||||
size: size(px(800.0), px(600.0)),
|
||||
}));
|
||||
|
||||
let workspace = SerializedWorkspace {
|
||||
id,
|
||||
paths: PathList::new(empty_paths),
|
||||
location: SerializedWorkspaceLocation::Local,
|
||||
center_group: Default::default(),
|
||||
window_bounds: None,
|
||||
display: None,
|
||||
docks: Default::default(),
|
||||
breakpoints: Default::default(),
|
||||
centered_layout: false,
|
||||
session_id: None,
|
||||
window_id: None,
|
||||
user_toolchains: Default::default(),
|
||||
};
|
||||
|
||||
// Save the workspace (this creates the record with empty paths)
|
||||
db.save_workspace(workspace.clone()).await;
|
||||
|
||||
// Save window bounds separately (as the actual code does via set_window_open_status)
|
||||
db.set_window_open_status(id, window_bounds, display_uuid)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Retrieve it using empty paths
|
||||
let retrieved = db.workspace_for_roots(empty_paths).unwrap();
|
||||
|
||||
// Verify window bounds were persisted
|
||||
assert_eq!(retrieved.id, id);
|
||||
assert!(retrieved.window_bounds.is_some());
|
||||
assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
|
||||
assert!(retrieved.display.is_some());
|
||||
assert_eq!(retrieved.display.unwrap(), display_uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1748,26 +1748,18 @@ impl Workspace {
|
||||
window
|
||||
} else {
|
||||
let window_bounds_override = window_bounds_env_override();
|
||||
let is_empty_workspace = project_paths.is_empty();
|
||||
|
||||
let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
|
||||
(Some(WindowBounds::Windowed(bounds)), None)
|
||||
} else if let Some(workspace) = serialized_workspace.as_ref() {
|
||||
} else if let Some(workspace) = serialized_workspace.as_ref()
|
||||
&& let Some(display) = workspace.display
|
||||
&& let Some(bounds) = workspace.window_bounds.as_ref()
|
||||
{
|
||||
// Reopening an existing workspace - restore its saved bounds
|
||||
if let (Some(display), Some(bounds)) =
|
||||
(workspace.display, workspace.window_bounds.as_ref())
|
||||
{
|
||||
(Some(bounds.0), Some(display))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else if is_empty_workspace {
|
||||
// Empty workspace - try to restore the last known no-project window bounds
|
||||
if let Some((display, bounds)) = persistence::read_default_window_bounds() {
|
||||
(Some(bounds), Some(display))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
(Some(bounds.0), Some(display))
|
||||
} else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
|
||||
// New or empty workspace - use the last known window bounds
|
||||
(Some(bounds), Some(display))
|
||||
} else {
|
||||
// New window - let GPUI's default_bounds() handle cascading
|
||||
(None, None)
|
||||
@@ -5673,12 +5665,24 @@ impl Workspace {
|
||||
persistence::DB.save_workspace(serialized_workspace).await;
|
||||
})
|
||||
}
|
||||
WorkspaceLocation::DetachFromSession => window.spawn(cx, async move |_| {
|
||||
persistence::DB
|
||||
.set_session_id(database_id, None)
|
||||
.await
|
||||
.log_err();
|
||||
}),
|
||||
WorkspaceLocation::DetachFromSession => {
|
||||
let window_bounds = SerializedWindowBounds(window.window_bounds());
|
||||
let display = window.display(cx).and_then(|d| d.uuid().ok());
|
||||
window.spawn(cx, async move |_| {
|
||||
persistence::DB
|
||||
.set_window_open_status(
|
||||
database_id,
|
||||
window_bounds,
|
||||
display.unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
persistence::DB
|
||||
.set_session_id(database_id, None)
|
||||
.await
|
||||
.log_err();
|
||||
})
|
||||
}
|
||||
WorkspaceLocation::None => Task::ready(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ cli.workspace = true
|
||||
client.workspace = true
|
||||
codestral.workspace = true
|
||||
collab_ui.workspace = true
|
||||
ollama.workspace = true
|
||||
collections.workspace = true
|
||||
command_palette.workspace = true
|
||||
component.workspace = true
|
||||
|
||||
BIN
crates/zed/resources/Document.icns
Normal file
BIN
crates/zed/resources/Document.icns
Normal file
Binary file not shown.
@@ -8,6 +8,7 @@ use feature_flags::FeatureFlagAppExt;
|
||||
use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity};
|
||||
use language::language_settings::{EditPredictionProvider, all_language_settings};
|
||||
use language_models::MistralLanguageModelProvider;
|
||||
use ollama::OllamaEditPredictionDelegate;
|
||||
use settings::{
|
||||
EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
|
||||
EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
|
||||
@@ -186,6 +187,11 @@ fn assign_edit_prediction_provider(
|
||||
let provider = cx.new(|_| CodestralEditPredictionDelegate::new(http_client));
|
||||
editor.set_edit_prediction_provider(Some(provider), window, cx);
|
||||
}
|
||||
EditPredictionProvider::Ollama => {
|
||||
let http_client = client.http_client();
|
||||
let provider = cx.new(|_| OllamaEditPredictionDelegate::new(http_client));
|
||||
editor.set_edit_prediction_provider(Some(provider), window, cx);
|
||||
}
|
||||
value @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => {
|
||||
let ep_store = edit_prediction::EditPredictionStore::global(client, &user_store, cx);
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ When there is an appropriate language server available, Zed will provide complet
|
||||
|
||||
You can manually trigger completions with `ctrl-space` or by triggering the `editor::ShowCompletions` action from the command palette.
|
||||
|
||||
> Note: Using `ctrl-space` in Zed requires disabling the macOS global shortcut.
|
||||
> Open **System Settings** > **Keyboard** > **Keyboard Shortcut**s >
|
||||
> **Input Sources** and uncheck **Select the previous input source**.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [Configuring Supported Languages](./configuring-languages.md)
|
||||
|
||||
@@ -106,6 +106,17 @@ mv Cargo.toml.backup Cargo.toml
|
||||
popd
|
||||
echo "Bundled ${app_path}"
|
||||
|
||||
# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns.
|
||||
# We use the app icon as a placeholder document icon for now.
|
||||
document_icon_source="crates/zed/resources/Document.icns"
|
||||
document_icon_target="${app_path}/Contents/Resources/Document.icns"
|
||||
if [[ -f "${document_icon_source}" ]]; then
|
||||
mkdir -p "$(dirname "${document_icon_target}")"
|
||||
cp "${document_icon_source}" "${document_icon_target}"
|
||||
else
|
||||
echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder."
|
||||
fi
|
||||
|
||||
if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
|
||||
can_code_sign=true
|
||||
|
||||
|
||||
81
script/verify-macos-document-icon
Executable file
81
script/verify-macos-document-icon
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
script/verify-macos-document-icon /path/to/Zed.app
|
||||
|
||||
Verifies that the given macOS app bundle's Info.plist references a document icon
|
||||
named "Document" and that the corresponding icon file exists in the bundle.
|
||||
|
||||
Specifically checks:
|
||||
- CFBundleDocumentTypes[*].CFBundleTypeIconFile includes "Document"
|
||||
- Contents/Resources/Document.icns exists
|
||||
|
||||
Exit codes:
|
||||
0 - success
|
||||
1 - verification failed
|
||||
2 - invalid usage / missing prerequisites
|
||||
USAGE
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
app_path="$1"
|
||||
|
||||
if [[ ! -d "${app_path}" ]]; then
|
||||
fail "app bundle not found: ${app_path}"
|
||||
fi
|
||||
|
||||
info_plist="${app_path}/Contents/Info.plist"
|
||||
if [[ ! -f "${info_plist}" ]]; then
|
||||
fail "missing Info.plist: ${info_plist}"
|
||||
fi
|
||||
|
||||
if ! command -v plutil >/dev/null 2>&1; then
|
||||
fail "plutil not found (required on macOS to read Info.plist)"
|
||||
fi
|
||||
|
||||
# Convert to JSON for robust parsing. plutil outputs JSON to stdout in this mode.
|
||||
info_json="$(plutil -convert json -o - "${info_plist}")"
|
||||
|
||||
# Check that CFBundleDocumentTypes exists and that at least one entry references "Document".
|
||||
# We use Python for JSON parsing; macOS ships with Python 3 on many setups, but not all.
|
||||
# If python3 isn't available, fall back to a simpler grep-based check.
|
||||
has_document_icon_ref="false"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
has_document_icon_ref="$(python3 -c "import json,sys; d=json.load(sys.stdin); types=d.get('CFBundleDocumentTypes', []); vals=[t.get('CFBundleTypeIconFile') for t in types if isinstance(t, dict)]; print('true' if 'Document' in vals else 'false')" <<<"${info_json}")"
|
||||
else
|
||||
# This is a best-effort fallback. It may produce false negatives if the JSON formatting differs.
|
||||
if echo "${info_json}" | grep -q '"CFBundleTypeIconFile"[[:space:]]*:[[:space:]]*"Document"'; then
|
||||
has_document_icon_ref="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${has_document_icon_ref}" != "true" ]]; then
|
||||
echo "Verification failed for: ${app_path}" >&2
|
||||
echo "Expected Info.plist to reference CFBundleTypeIconFile \"Document\" in CFBundleDocumentTypes." >&2
|
||||
echo "Tip: This bundle may be missing DocumentTypes.plist extensions or may have different icon naming." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
document_icon_path="${app_path}/Contents/Resources/Document.icns"
|
||||
if [[ ! -f "${document_icon_path}" ]]; then
|
||||
echo "Verification failed for: ${app_path}" >&2
|
||||
echo "Expected document icon to exist: ${document_icon_path}" >&2
|
||||
echo "Tip: The bundle script should copy crates/zed/resources/Document.icns into Contents/Resources/Document.icns." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: ${app_path}"
|
||||
echo " - Info.plist references CFBundleTypeIconFile \"Document\""
|
||||
echo " - Found ${document_icon_path}"
|
||||
@@ -45,11 +45,15 @@ pub(crate) fn run_tests() -> Workflow {
|
||||
&should_run_tests,
|
||||
]);
|
||||
|
||||
let check_style = check_style();
|
||||
let run_tests_linux = run_platform_tests(Platform::Linux);
|
||||
let call_autofix = call_autofix(&check_style, &run_tests_linux);
|
||||
|
||||
let mut jobs = vec![
|
||||
orchestrate,
|
||||
check_style(),
|
||||
check_style,
|
||||
should_run_tests.guard(run_platform_tests(Platform::Windows)),
|
||||
should_run_tests.guard(run_platform_tests(Platform::Linux)),
|
||||
should_run_tests.guard(run_tests_linux),
|
||||
should_run_tests.guard(run_platform_tests(Platform::Mac)),
|
||||
should_run_tests.guard(doctests()),
|
||||
should_run_tests.guard(check_workspace_binaries()),
|
||||
@@ -106,6 +110,7 @@ pub(crate) fn run_tests() -> Workflow {
|
||||
workflow
|
||||
})
|
||||
.add_job(tests_pass.name, tests_pass.job)
|
||||
.add_job(call_autofix.name, call_autofix.job)
|
||||
}
|
||||
|
||||
// Generates a bash script that checks changed files against regex patterns
|
||||
@@ -221,6 +226,8 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
|
||||
named::job(job)
|
||||
}
|
||||
|
||||
pub const STYLE_FAILED_OUTPUT: &str = "style_failed";
|
||||
|
||||
fn check_style() -> NamedJob {
|
||||
fn check_for_typos() -> Step<Use> {
|
||||
named::uses(
|
||||
@@ -236,15 +243,58 @@ fn check_style() -> NamedJob {
|
||||
.add_step(steps::checkout_repo())
|
||||
.add_step(steps::cache_rust_dependencies_namespace())
|
||||
.add_step(steps::setup_pnpm())
|
||||
.add_step(steps::script("./script/prettier"))
|
||||
.add_step(steps::prettier())
|
||||
.add_step(steps::cargo_fmt())
|
||||
.add_step(steps::trigger_autofix(false))
|
||||
.add_step(steps::record_style_failure())
|
||||
.add_step(steps::script("./script/check-todos"))
|
||||
.add_step(steps::script("./script/check-keymaps"))
|
||||
.add_step(check_for_typos()),
|
||||
.add_step(check_for_typos())
|
||||
.outputs([(
|
||||
STYLE_FAILED_OUTPUT.to_owned(),
|
||||
format!(
|
||||
"${{{{ steps.{}.outputs.failed == 'true' }}}}",
|
||||
steps::RECORD_STYLE_FAILURE_STEP_ID
|
||||
),
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
fn call_autofix(check_style: &NamedJob, run_tests_linux: &NamedJob) -> NamedJob {
|
||||
fn dispatch_autofix(run_tests_linux_name: &str) -> Step<Run> {
|
||||
let clippy_failed_expr = format!(
|
||||
"needs.{}.outputs.{} == 'true'",
|
||||
run_tests_linux_name, CLIPPY_FAILED_OUTPUT
|
||||
);
|
||||
named::bash(format!(
|
||||
"gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy=${{{{ {} }}}}",
|
||||
clippy_failed_expr
|
||||
))
|
||||
.add_env(("GITHUB_TOKEN", "${{ steps.get-app-token.outputs.token }}"))
|
||||
}
|
||||
|
||||
let style_failed_expr = format!(
|
||||
"needs.{}.outputs.{} == 'true'",
|
||||
check_style.name, STYLE_FAILED_OUTPUT
|
||||
);
|
||||
let clippy_failed_expr = format!(
|
||||
"needs.{}.outputs.{} == 'true'",
|
||||
run_tests_linux.name, CLIPPY_FAILED_OUTPUT
|
||||
);
|
||||
let (authenticate, _token) = steps::authenticate_as_zippy();
|
||||
|
||||
let job = Job::default()
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.cond(Expression::new(format!(
|
||||
"always() && ({} || {}) && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'",
|
||||
style_failed_expr, clippy_failed_expr
|
||||
)))
|
||||
.needs(vec![check_style.name.clone(), run_tests_linux.name.clone()])
|
||||
.add_step(authenticate)
|
||||
.add_step(dispatch_autofix(&run_tests_linux.name));
|
||||
|
||||
named::job(job)
|
||||
}
|
||||
|
||||
fn check_dependencies() -> NamedJob {
|
||||
fn install_cargo_machete() -> Step<Use> {
|
||||
named::uses(
|
||||
@@ -305,6 +355,8 @@ fn check_workspace_binaries() -> NamedJob {
|
||||
)
|
||||
}
|
||||
|
||||
pub const CLIPPY_FAILED_OUTPUT: &str = "clippy_failed";
|
||||
|
||||
pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob {
|
||||
let runner = match platform {
|
||||
Platform::Windows => runners::WINDOWS_DEFAULT,
|
||||
@@ -327,12 +379,23 @@ pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob {
|
||||
.add_step(steps::setup_node())
|
||||
.add_step(steps::clippy(platform))
|
||||
.when(platform == Platform::Linux, |job| {
|
||||
job.add_step(steps::trigger_autofix(true))
|
||||
.add_step(steps::cargo_install_nextest())
|
||||
job.add_step(steps::record_clippy_failure())
|
||||
})
|
||||
.when(platform == Platform::Linux, |job| {
|
||||
job.add_step(steps::cargo_install_nextest())
|
||||
})
|
||||
.add_step(steps::clear_target_dir_if_large(platform))
|
||||
.add_step(steps::cargo_nextest(platform))
|
||||
.add_step(steps::cleanup_cargo_config(platform)),
|
||||
.add_step(steps::cleanup_cargo_config(platform))
|
||||
.when(platform == Platform::Linux, |job| {
|
||||
job.outputs([(
|
||||
CLIPPY_FAILED_OUTPUT.to_owned(),
|
||||
format!(
|
||||
"${{{{ steps.{}.outputs.failed == 'true' }}}}",
|
||||
steps::RECORD_CLIPPY_FAILURE_STEP_ID
|
||||
),
|
||||
)])
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,25 @@ pub fn setup_sentry() -> Step<Use> {
|
||||
.add_with(("token", vars::SENTRY_AUTH_TOKEN))
|
||||
}
|
||||
|
||||
pub const PRETTIER_STEP_ID: &str = "prettier";
|
||||
pub const CARGO_FMT_STEP_ID: &str = "cargo_fmt";
|
||||
pub const RECORD_STYLE_FAILURE_STEP_ID: &str = "record_style_failure";
|
||||
|
||||
pub fn prettier() -> Step<Run> {
|
||||
named::bash("./script/prettier").id(PRETTIER_STEP_ID)
|
||||
}
|
||||
|
||||
pub fn cargo_fmt() -> Step<Run> {
|
||||
named::bash("cargo fmt --all -- --check")
|
||||
named::bash("cargo fmt --all -- --check").id(CARGO_FMT_STEP_ID)
|
||||
}
|
||||
|
||||
pub fn record_style_failure() -> Step<Run> {
|
||||
named::bash(format!(
|
||||
"echo \"failed=${{{{ steps.{}.outcome == 'failure' || steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"",
|
||||
PRETTIER_STEP_ID, CARGO_FMT_STEP_ID
|
||||
))
|
||||
.id(RECORD_STYLE_FAILURE_STEP_ID)
|
||||
.if_condition(Expression::new("always()"))
|
||||
}
|
||||
|
||||
pub fn cargo_install_nextest() -> Step<Use> {
|
||||
@@ -101,13 +118,25 @@ pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
|
||||
}
|
||||
}
|
||||
|
||||
pub const CLIPPY_STEP_ID: &str = "clippy";
|
||||
pub const RECORD_CLIPPY_FAILURE_STEP_ID: &str = "record_clippy_failure";
|
||||
|
||||
pub fn clippy(platform: Platform) -> Step<Run> {
|
||||
match platform {
|
||||
Platform::Windows => named::pwsh("./script/clippy.ps1"),
|
||||
_ => named::bash("./script/clippy"),
|
||||
Platform::Windows => named::pwsh("./script/clippy.ps1").id(CLIPPY_STEP_ID),
|
||||
_ => named::bash("./script/clippy").id(CLIPPY_STEP_ID),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_clippy_failure() -> Step<Run> {
|
||||
named::bash(format!(
|
||||
"echo \"failed=${{{{ steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"",
|
||||
CLIPPY_STEP_ID
|
||||
))
|
||||
.id(RECORD_CLIPPY_FAILURE_STEP_ID)
|
||||
.if_condition(Expression::new("always()"))
|
||||
}
|
||||
|
||||
pub fn cache_rust_dependencies_namespace() -> Step<Use> {
|
||||
named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "rust"))
|
||||
}
|
||||
@@ -345,16 +374,6 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn trigger_autofix(run_clippy: bool) -> Step<Run> {
|
||||
named::bash(format!(
|
||||
"gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy={run_clippy}"
|
||||
))
|
||||
.if_condition(Expression::new(
|
||||
"failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'",
|
||||
))
|
||||
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
|
||||
}
|
||||
|
||||
pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
|
||||
let step = named::uses(
|
||||
"actions",
|
||||
|
||||
Reference in New Issue
Block a user