Compare commits

...

19 Commits

Author SHA1 Message Date
Agus Zubiaga
5cc3b3a04f wait for workspace window to show 2025-12-17 23:00:30 -03:00
Agus Zubiaga
422dc4f307 Reuse cloning code from git_panel 2025-12-17 21:50:05 -03:00
Agus Zubiaga
b1aa0e2efd Use git/commit style format 2025-12-17 21:10:05 -03:00
Agus Zubiaga
3dbfee1c47 Merge branch 'main' into git-clone 2025-12-17 20:53:10 -03:00
Kingsword
0c9992c5e9 terminal: Forward Ctrl+V when clipboard contains images (#42258)
When running Codex CLI, Claude Code, or other TUI agents in Zed’s
terminal, pasting images wasn’t supported — Zed
treated all clipboard content as plain text and simply pushed it into
the PTY, so the agent never saw the image data.
This change makes terminal pastes behave like they do in a native
terminal: if the clipboard contains an image, Zed now emits a raw Ctrl+V
to the PTY so the agent can read the system clipboard itself.

Release Notes:

- Fixed terminal-launched Codex/Claude sessions by forwarding Ctrl+V for
clipboard images so agents can attach them
2025-12-17 20:42:47 -03:00
Mayank Verma
cec46079fe git_ui: Preserve newlines in commit messages (#45167)
Closes #44982

Release Notes:

- Fixed Git panel to preserve newlines in commit messages
2025-12-17 22:52:10 +00:00
Ben Kunkle
f9b69aeff0 Fix Wayland platform resize resulting in non-interactive window (#45153)
Closes  #40361

Release Notes:

- Linux(Wayland): Fixed an issue where the settings window would not
respond to user interaction until resized
2025-12-17 17:44:25 -05:00
Nathan Sobo
f00cb371f4 macOS: Bundle placeholder Document.icns so Finder can display Zed file icons (#44833)
Generated by AI.

`DocumentTypes.plist` declares `CFBundleTypeIconFile` as `Document` for
Zed’s document types, but the macOS bundle did not include
`Contents/Resources/Document.icns`, causing Finder to fall back to
generic icons.

This PR:
- Adds `crates/zed/resources/Document.icns` as a placeholder document
icon (currently derived from the app icon).
- Updates `script/bundle-mac` to copy it into the `.app` at
`Contents/Resources/Document.icns` during bundling.
- Adds `script/verify-macos-document-icon` for one-command validation.

## How to test (CLI)
1. Build a debug bundle:
   - `./script/bundle-mac -d aarch64-apple-darwin`
2. Verify the bundle contains the referenced icon:
- `./script/verify-macos-document-icon
"target/aarch64-apple-darwin/debug/bundle/osx/Zed Dev.app"`

## Optional visual validation in Finder
- Pick a file (e.g. `.rs`), Get Info → Open with: Zed Dev → Change
All...
- Restart Finder: `killall Finder` (or log out/in)

@JosephTLyons — would you mind running the steps above and confirming
Finder shows Zed’s icon for source files after "Change All" + Finder
restart?

@danilo-leal — this PR ships a placeholder `Document.icns`. When the
real document icon is ready, replace
`crates/zed/resources/Document.icns` and the bundling script will
include it automatically.


Closes #44403.

Release Notes:

- TODO

---------

Co-authored-by: Matt Miller <mattrx@gmail.com>
2025-12-17 16:42:31 -06:00
Ben Kunkle
25e1e2ecdd Don't trigger autosave on focus change in modals (#45166)
Closes #28732

Release Notes:

- Opening the command palette or other modals no longer triggers
auto-save with the `{ "autosave": "on_focus_change" }` setting. This
reduces the chance of unwanted format changes when executing actions,
and fixes a race condition with `:w` in Vim mode
2025-12-17 17:42:18 -05:00
Conrad Irwin
f2d29f4790 Auto-release preview as Zippy (#45163)
I think we're not triggering the after-release workflow because of
github's loop detection when you use the default GITHUB_TOKEN

Closes #ISSUE

Release Notes:

- N/A
2025-12-17 15:32:28 -07:00
LoricAndre
623e13761b git: Unify commit popups (#38749)
Closes #26424
Supersedes #35328

Originally, `git::blame` uses its own `ParsedCommitMessage` as the
source for the commit information, including the PR section. This
changes unifies this with `git::repository` and `git_ui::git_panel` by
moving this and some other commit-related structs to `git::commit`
instead, and making both `git_ui::blame_ui` and `git_ui::git_panel` pull
their information from these structs.

Release notes :

- (Let's Git Together) Fixed the commit tooltip in the git panel not
showing information like avatars.

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-17 17:31:12 -05:00
Danilo Leal
302a4bbdd0 git panel: Fix file path truncation and add some UI code clean up (#45161)
This PR ensures truncation works for the file paths, which should set up
the stage for when the new GPUI `truncation_start` method lands
(https://github.com/zed-industries/zed/pull/45122) so that we can use
for them. In the process of doing so and figuring it out why it wasn't
working as well before, I noticed some opportunities to clean up some UI
code: removing unnecessary styles, making the file easier to navigate
given all of the different UI conditions, etc.

Note: You might notice a subtle label flashing that comes with the label
truncation and that's a standalone GPUI bug that's also visible in other
surface areas of the app. I don't think it should block these changes
here as it's something we should fix on its own...

Release Notes:

- N/A
2025-12-17 19:28:27 -03:00
Kirill Bulatov
c4f8f2fbf4 Use less generic globs for JSONC to avoid overmatching (#45162)
Otherwise, all *.json files under `zed` directory will be matched as
JSONC, e.g `zed/crates/vim/test_data/test_a.json` which is not right.
On top, `globset` considers that `zed/crates/vim/test_data/test_a.json`
matches `**/zed/*.json` glob (!).

Release Notes:

- N/A
2025-12-17 22:22:37 +00:00
Cameron Mcloughlin
52c7447106 gpui: Add Vietnamese chars to LineWrapper::is_word_char (#45160) 2025-12-17 21:53:12 +00:00
Alvaro Parker
4930d3aa80 Improve code 2025-11-11 08:52:04 -03:00
Alvaro Parker
5d633a3968 Remove modal indicator 2025-11-11 08:52:04 -03:00
Alvaro Parker
6967ea41e5 Open project panel on success 2025-11-11 08:52:04 -03:00
Alvaro Parker
5068581b39 Add loading modal 2025-11-11 08:52:04 -03:00
Alvaro Parker
f3bd6b88db WIP Git clone 2025-11-11 08:52:04 -03:00
28 changed files with 725 additions and 406 deletions

View File

@@ -90,7 +90,7 @@ jobs:
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: get-app-token
name: autofix_pr::commit_changes::authenticate_as_zippy
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}

View File

@@ -30,7 +30,7 @@ jobs:
with:
clean: false
- id: get-app-token
name: cherry_pick::run_cherry_pick::authenticate_as_zippy
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}

View File

@@ -478,11 +478,17 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
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: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
notify_on_failure:
needs:
- upload_release_assets

View File

@@ -1705,7 +1705,12 @@
// }
//
"file_types": {
"JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"],
"JSONC": [
"**/.zed/*.json",
"**/.vscode/**/*.json",
"**/{zed,Zed}/{settings,keymap,tasks,debug}.json",
"tsconfig*.json",
],
"Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"],
"Shell Script": [".env.*"],
},

View File

@@ -73,11 +73,7 @@ pub use multi_buffer::{
pub use split::SplittableEditor;
pub use text::Bias;
use ::git::{
Restore,
blame::{BlameEntry, ParsedCommitMessage},
status::FileStatus,
};
use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
use anyhow::{Context as _, Result, anyhow, bail};
use blink_manager::BlinkManager;

View File

@@ -37,11 +37,7 @@ use crate::{
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
use collections::{BTreeMap, HashMap};
use file_icons::FileIcons;
use git::{
Oid,
blame::{BlameEntry, ParsedCommitMessage},
status::FileStatus,
};
use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
use gpui::{
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,

View File

@@ -3,9 +3,9 @@ use anyhow::{Context as _, Result};
use collections::HashMap;
use git::{
GitHostingProviderRegistry, GitRemote, Oid,
blame::{Blame, BlameEntry, ParsedCommitMessage},
parse_git_remote_url,
GitHostingProviderRegistry, Oid,
blame::{Blame, BlameEntry},
commit::ParsedCommitMessage,
};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
@@ -525,12 +525,7 @@ impl GitBlame {
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
.and_then(|(repo, _)| {
repo.read(cx)
.remote_upstream_url
.clone()
.or(repo.read(cx).remote_origin_url.clone())
});
.and_then(|(repo, _)| repo.read(cx).default_remote_url());
let blame_buffer = project
.update(cx, |project, cx| project.blame_buffer(&buffer, None, cx));
Ok(async move {
@@ -554,13 +549,19 @@ impl GitBlame {
entries,
snapshot.max_point().row,
);
let commit_details = parse_commit_messages(
messages,
remote_url,
provider_registry.clone(),
)
.await;
let commit_details = messages
.into_iter()
.map(|(oid, message)| {
let parsed_commit_message =
ParsedCommitMessage::parse(
oid.to_string(),
message,
remote_url.as_deref(),
Some(provider_registry.clone()),
);
(oid, parsed_commit_message)
})
.collect();
res.push((
id,
snapshot,
@@ -680,55 +681,6 @@ fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree
entries
}
async fn parse_commit_messages(
messages: impl IntoIterator<Item = (Oid, String)>,
remote_url: Option<String>,
provider_registry: Arc<GitHostingProviderRegistry>,
) -> HashMap<Oid, ParsedCommitMessage> {
let mut commit_details = HashMap::default();
let parsed_remote_url = remote_url
.as_deref()
.and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url));
for (oid, message) in messages {
let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() {
Some(provider.build_commit_permalink(
git_remote,
git::BuildCommitPermalinkParams {
sha: oid.to_string().as_str(),
},
))
} else {
None
};
let remote = parsed_remote_url
.as_ref()
.map(|(provider, remote)| GitRemote {
host: provider.clone(),
owner: remote.owner.clone().into(),
repo: remote.repo.clone().into(),
});
let pull_request = parsed_remote_url
.as_ref()
.and_then(|(provider, remote)| provider.extract_pull_request(remote, &message));
commit_details.insert(
oid,
ParsedCommitMessage {
message: message.into(),
permalink,
remote,
pull_request,
},
);
}
commit_details
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,10 +1,9 @@
use crate::Oid;
use crate::commit::get_messages;
use crate::repository::RepoPath;
use crate::{GitRemote, Oid};
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use futures::AsyncWriteExt;
use gpui::SharedString;
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::{ops::Range, path::Path};
@@ -21,14 +20,6 @@ pub struct Blame {
pub messages: HashMap<Oid, String>,
}
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
pub message: SharedString,
pub permalink: Option<url::Url>,
pub pull_request: Option<crate::hosting_provider::PullRequest>,
pub remote: Option<GitRemote>,
}
impl Blame {
pub async fn for_path(
git_binary: &Path,

View File

@@ -1,7 +1,52 @@
use crate::{Oid, status::StatusCode};
use crate::{
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
status::StatusCode,
};
use anyhow::{Context as _, Result};
use collections::HashMap;
use std::path::Path;
use gpui::SharedString;
use std::{path::Path, sync::Arc};
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
pub message: SharedString,
pub permalink: Option<url::Url>,
pub pull_request: Option<crate::hosting_provider::PullRequest>,
pub remote: Option<GitRemote>,
}
impl ParsedCommitMessage {
pub fn parse(
sha: String,
message: String,
remote_url: Option<&str>,
provider_registry: Option<Arc<GitHostingProviderRegistry>>,
) -> Self {
if let Some((hosting_provider, remote)) = provider_registry
.and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url)))
{
let pull_request = hosting_provider.extract_pull_request(&remote, &message);
Self {
message: message.into(),
permalink: Some(
hosting_provider
.build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }),
),
pull_request,
remote: Some(GitRemote {
host: hosting_provider,
owner: remote.owner.into(),
repo: remote.repo.into(),
}),
}
} else {
Self {
message: message.into(),
..Default::default()
}
}
}
}
pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
if shas.is_empty() {

View File

@@ -3,10 +3,7 @@ use crate::{
commit_view::CommitView,
};
use editor::{BlameRenderer, Editor, hover_markdown_style};
use git::{
blame::{BlameEntry, ParsedCommitMessage},
repository::CommitSummary,
};
use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
use gpui::{
ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle,
TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,

155
crates/git_ui/src/clone.rs Normal file
View File

@@ -0,0 +1,155 @@
use gpui::{App, Context, WeakEntity, Window};
use notifications::status_toast::{StatusToast, ToastIcon};
use std::sync::Arc;
use ui::{Color, IconName, SharedString};
use util::ResultExt;
use workspace::{self, Workspace};
pub fn clone_and_open(
repo_url: SharedString,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
on_success: Arc<
dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
>,
) {
let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select as Repository Destination".into()),
});
window
.spawn(cx, async move |cx| {
let mut paths = destination_prompt.await.ok()?.ok()??;
let mut destination_dir = paths.pop()?;
let repo_name = repo_url
.split('/')
.next_back()
.map(|name| name.strip_suffix(".git").unwrap_or(name))
.unwrap_or("repository")
.to_owned();
let clone_task = workspace
.update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
let destination_dir = destination_dir.clone();
let repo_url = repo_url.clone();
cx.spawn(async move |_workspace, _cx| {
fs.git_clone(&repo_url, destination_dir.as_path()).await
})
})
.ok()?;
if let Err(error) = clone_task.await {
workspace
.update(cx, |workspace, cx| {
let toast = StatusToast::new(error.to_string(), cx, |this, _| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.dismiss_button(true)
});
workspace.toggle_status_toast(toast, cx);
})
.log_err();
return None;
}
let has_worktrees = workspace
.read_with(cx, |workspace, cx| {
workspace.project().read(cx).worktrees(cx).next().is_some()
})
.ok()?;
let prompt_answer = if has_worktrees {
cx.update(|window, cx| {
window.prompt(
gpui::PromptLevel::Info,
&format!("Git Clone: {}", repo_name),
None,
&["Add repo to project", "Open repo in new project"],
cx,
)
})
.ok()?
.await
.ok()?
} else {
// Don't ask if project is empty
0
};
destination_dir.push(&repo_name);
match prompt_answer {
0 => {
workspace
.update_in(cx, |workspace, window, cx| {
let create_task = workspace.project().update(cx, |project, cx| {
project.create_worktree(destination_dir.as_path(), true, cx)
});
let workspace_weak = cx.weak_entity();
let on_success = on_success.clone();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
})
.ok()?;
}
1 => {
workspace
.update(cx, move |workspace, cx| {
let app_state = workspace.app_state().clone();
let destination_path = destination_dir.clone();
let on_success = on_success.clone();
workspace::open_new(
Default::default(),
app_state,
cx,
move |workspace, window, cx| {
cx.activate(true);
let create_task =
workspace.project().update(cx, |project, cx| {
project.create_worktree(
destination_path.as_path(),
true,
cx,
)
});
let workspace_weak = cx.weak_entity();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
},
)
.detach();
})
.ok();
}
_ => {}
}
Some(())
})
.detach();
}

View File

@@ -3,7 +3,7 @@ use editor::hover_markdown_style;
use futures::Future;
use git::blame::BlameEntry;
use git::repository::CommitSummary;
use git::{GitRemote, blame::ParsedCommitMessage};
use git::{GitRemote, commit::ParsedCommitMessage};
use gpui::{
App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
StatefulInteractiveElement, WeakEntity, prelude::*,

View File

@@ -15,12 +15,13 @@ 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,
};
use futures::StreamExt as _;
use git::blame::ParsedCommitMessage;
use git::commit::ParsedCommitMessage;
use git::repository::{
Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
@@ -30,15 +31,14 @@ use git::stash::GitStash;
use git::status::StageStatus;
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
use git::{
ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop,
TrashUntrackedFiles, UnstageAll,
ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
StashApply, StashPop, TrashUntrackedFiles, UnstageAll,
};
use gpui::{
Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point,
size, uniform_list,
EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
anchored, deferred, point, size, uniform_list,
};
use itertools::Itertools;
use language::{Buffer, File};
@@ -212,8 +212,7 @@ const GIT_PANEL_KEY: &str = "GitPanel";
const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
const TREE_INDENT: f32 = 12.0;
const TREE_INDENT_GUIDE_OFFSET: f32 = 16.0;
const TREE_INDENT: f32 = 16.0;
pub fn register(workspace: &mut Workspace) {
workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
@@ -2182,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() {
@@ -2843,93 +2848,15 @@ impl GitPanel {
}
pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
let path = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select as Repository Destination".into()),
});
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |this, cx| {
let mut paths = path.await.ok()?.ok()??;
let mut path = paths.pop()?;
let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned();
let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
Ok(_) => cx.update(|window, cx| {
window.prompt(
PromptLevel::Info,
&format!("Git Clone: {}", repo_name),
None,
&["Add repo to project", "Open repo in new project"],
cx,
)
}),
Err(e) => {
this.update(cx, |this: &mut GitPanel, cx| {
let toast = StatusToast::new(e.to_string(), cx, |this, _| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.dismiss_button(true)
});
this.workspace
.update(cx, |workspace, cx| {
workspace.toggle_status_toast(toast, cx);
})
.ok();
})
.ok()?;
return None;
}
}
.ok()?;
path.push(repo_name);
match prompt_answer.await.ok()? {
0 => {
workspace
.update(cx, |workspace, cx| {
workspace
.project()
.update(cx, |project, cx| {
project.create_worktree(path.as_path(), true, cx)
})
.detach();
})
.ok();
}
1 => {
workspace
.update(cx, move |workspace, cx| {
workspace::open_new(
Default::default(),
workspace.app_state().clone(),
cx,
move |workspace, _, cx| {
cx.activate(true);
workspace
.project()
.update(cx, |project, cx| {
project.create_worktree(&path, true, cx)
})
.detach();
},
)
.detach();
})
.ok();
}
_ => {}
}
Some(())
})
.detach();
crate::clone::clone_and_open(
repo.into(),
workspace,
window,
cx,
Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
);
}
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -4697,7 +4624,10 @@ impl GitPanel {
},
)
.with_render_fn(cx.entity(), |_, params, _, _| {
let left_offset = px(TREE_INDENT_GUIDE_OFFSET);
// Magic number to align the tree item is 3 here
// because we're using 12px as the left-side padding
// and 3 makes the alignment work with the bounding box of the icon
let left_offset = px(TREE_INDENT + 3_f32);
let indent_size = params.indent_size;
let item_height = params.item_height;
@@ -4725,10 +4655,6 @@ impl GitPanel {
})
.size_full()
.flex_grow()
.with_sizing_behavior(ListSizingBehavior::Auto)
.with_horizontal_sizing_behavior(
ListHorizontalSizingBehavior::Unconstrained,
)
.with_width_from_item(self.max_width_item_index)
.track_scroll(&self.scroll_handle),
)
@@ -4752,7 +4678,7 @@ impl GitPanel {
}
fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
Label::new(label.into()).color(color).single_line()
Label::new(label.into()).color(color)
}
fn list_item_height(&self) -> Rems {
@@ -4774,8 +4700,8 @@ impl GitPanel {
.h(self.list_item_height())
.w_full()
.items_end()
.px(rems(0.75)) // ~12px
.pb(rems(0.3125)) // ~ 5px
.px_3()
.pb_1()
.child(
Label::new(header.title())
.color(Color::Muted)
@@ -4963,113 +4889,68 @@ impl GitPanel {
let marked_bg_alpha = 0.12;
let state_opacity_step = 0.04;
let info_color = cx.theme().status().info;
let base_bg = match (selected, marked) {
(true, true) => cx
.theme()
.status()
.info
.alpha(selected_bg_alpha + marked_bg_alpha),
(true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
(false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
(true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
(true, false) => info_color.alpha(selected_bg_alpha),
(false, true) => info_color.alpha(marked_bg_alpha),
_ => cx.theme().colors().ghost_element_background,
};
let hover_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step)
} else {
cx.theme().colors().ghost_element_hover
};
let active_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step * 2.0)
} else {
cx.theme().colors().ghost_element_active
};
let mut name_row = h_flex()
.items_center()
.gap_1()
.flex_1()
.pl(if tree_view {
px(depth as f32 * TREE_INDENT)
} else {
px(0.)
})
.child(git_status_icon(status));
name_row = if tree_view {
name_row.child(
self.entry_label(display_name, label_color)
.when(status.is_deleted(), Label::strikethrough)
.truncate(),
let (hover_bg, active_bg) = if selected {
(
info_color.alpha(selected_bg_alpha + state_opacity_step),
info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
)
} else {
name_row.child(h_flex().items_center().flex_1().map(|this| {
self.path_formatted(
this,
entry.parent_dir(path_style),
path_color,
display_name,
label_color,
path_style,
git_path_style,
status.is_deleted(),
)
}))
(
cx.theme().colors().ghost_element_hover,
cx.theme().colors().ghost_element_active,
)
};
let name_row = h_flex()
.min_w_0()
.flex_1()
.gap_1()
.child(git_status_icon(status))
.map(|this| {
if tree_view {
this.pl(px(depth as f32 * TREE_INDENT)).child(
self.entry_label(display_name, label_color)
.when(status.is_deleted(), Label::strikethrough)
.truncate(),
)
} else {
this.child(self.path_formatted(
entry.parent_dir(path_style),
path_color,
display_name,
label_color,
path_style,
git_path_style,
status.is_deleted(),
))
}
});
h_flex()
.id(id)
.h(self.list_item_height())
.w_full()
.pl_3()
.pr_1()
.gap_1p5()
.border_1()
.border_r_2()
.when(selected && self.focus_handle.is_focused(window), |el| {
el.border_color(cx.theme().colors().panel_focused_border)
})
.px(rems(0.75)) // ~12px
.overflow_hidden()
.flex_none()
.gap_1p5()
.bg(base_bg)
.hover(|this| this.bg(hover_bg))
.active(|this| this.bg(active_bg))
.on_click({
cx.listener(move |this, event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
cx.notify();
if event.modifiers().secondary() {
this.open_file(&Default::default(), window, cx)
} else {
this.open_diff(&Default::default(), window, cx);
this.focus_handle.focus(window, cx);
}
})
})
.on_mouse_down(
MouseButton::Right,
move |event: &MouseDownEvent, window, cx| {
// why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
if event.button != MouseButton::Right {
return;
}
let Some(this) = handle.upgrade() else {
return;
};
this.update(cx, |this, cx| {
this.deploy_entry_context_menu(event.position, ix, window, cx);
});
cx.stop_propagation();
},
)
.child(name_row.overflow_x_hidden())
.hover(|s| s.bg(hover_bg))
.active(|s| s.bg(active_bg))
.child(name_row)
.child(
div()
.id(checkbox_wrapper_id)
@@ -5119,6 +5000,35 @@ impl GitPanel {
}),
),
)
.on_click({
cx.listener(move |this, event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
cx.notify();
if event.modifiers().secondary() {
this.open_file(&Default::default(), window, cx)
} else {
this.open_diff(&Default::default(), window, cx);
this.focus_handle.focus(window, cx);
}
})
})
.on_mouse_down(
MouseButton::Right,
move |event: &MouseDownEvent, window, cx| {
// why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
if event.button != MouseButton::Right {
return;
}
let Some(this) = handle.upgrade() else {
return;
};
this.update(cx, |this, cx| {
this.deploy_entry_context_menu(event.position, ix, window, cx);
});
cx.stop_propagation();
},
)
.into_any_element()
}
@@ -5143,29 +5053,23 @@ impl GitPanel {
let selected_bg_alpha = 0.08;
let state_opacity_step = 0.04;
let base_bg = if selected {
cx.theme().status().info.alpha(selected_bg_alpha)
let info_color = cx.theme().status().info;
let colors = cx.theme().colors();
let (base_bg, hover_bg, active_bg) = if selected {
(
info_color.alpha(selected_bg_alpha),
info_color.alpha(selected_bg_alpha + state_opacity_step),
info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
)
} else {
cx.theme().colors().ghost_element_background
(
colors.ghost_element_background,
colors.ghost_element_hover,
colors.ghost_element_active,
)
};
let hover_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step)
} else {
cx.theme().colors().ghost_element_hover
};
let active_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step * 2.0)
} else {
cx.theme().colors().ghost_element_active
};
let folder_icon = if entry.expanded {
IconName::FolderOpen
} else {
@@ -5188,9 +5092,8 @@ impl GitPanel {
};
let name_row = h_flex()
.items_center()
.min_w_0()
.gap_1()
.flex_1()
.pl(px(entry.depth as f32 * TREE_INDENT))
.child(
Icon::new(folder_icon)
@@ -5202,28 +5105,21 @@ impl GitPanel {
h_flex()
.id(id)
.h(self.list_item_height())
.min_w_0()
.w_full()
.items_center()
.pl_3()
.pr_1()
.gap_1p5()
.justify_between()
.border_1()
.border_r_2()
.when(selected && self.focus_handle.is_focused(window), |el| {
el.border_color(cx.theme().colors().panel_focused_border)
})
.px(rems(0.75))
.overflow_hidden()
.flex_none()
.gap_1p5()
.bg(base_bg)
.hover(|this| this.bg(hover_bg))
.active(|this| this.bg(active_bg))
.on_click({
let key = entry.key.clone();
cx.listener(move |this, _event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
this.toggle_directory(&key, window, cx);
})
})
.child(name_row.overflow_x_hidden())
.hover(|s| s.bg(hover_bg))
.active(|s| s.bg(active_bg))
.child(name_row)
.child(
div()
.id(checkbox_wrapper_id)
@@ -5262,12 +5158,18 @@ impl GitPanel {
}),
),
)
.on_click({
let key = entry.key.clone();
cx.listener(move |this, _event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
this.toggle_directory(&key, window, cx);
})
})
.into_any_element()
}
fn path_formatted(
&self,
parent: Div,
directory: Option<String>,
path_color: Color,
file_name: String,
@@ -5276,42 +5178,32 @@ impl GitPanel {
git_path_style: GitPathStyle,
strikethrough: bool,
) -> Div {
parent
.when(git_path_style == GitPathStyle::FileNameFirst, |this| {
this.child(
self.entry_label(
match directory.as_ref().is_none_or(|d| d.is_empty()) {
true => file_name.clone(),
false => format!("{file_name} "),
},
label_color,
)
.when(strikethrough, Label::strikethrough),
)
})
.when_some(directory, |this, dir| {
match (
!dir.is_empty(),
git_path_style == GitPathStyle::FileNameFirst,
) {
(true, true) => this.child(
self.entry_label(dir, path_color)
.when(strikethrough, Label::strikethrough),
),
(true, false) => this.child(
self.entry_label(
format!("{dir}{}", path_style.primary_separator()),
path_color,
)
.when(strikethrough, Label::strikethrough),
),
_ => this,
}
})
.when(git_path_style == GitPathStyle::FilePathFirst, |this| {
this.child(
let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
let file_name = format!("{} ", file_name);
h_flex()
.min_w_0()
.overflow_hidden()
.when(file_path_first, |this| this.flex_row_reverse())
.child(
div().flex_none().child(
self.entry_label(file_name, label_color)
.when(strikethrough, Label::strikethrough),
),
)
.when_some(directory, |this, dir| {
let path_name = if file_name_first {
dir
} else {
format!("{dir}{}", path_style.primary_separator())
};
this.child(
self.entry_label(path_name, path_color)
.truncate()
.when(strikethrough, Label::strikethrough),
)
})
}
@@ -5650,6 +5542,7 @@ impl GitPanelMessageTooltip {
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let remote_url = repository.read(cx).default_remote_url();
cx.new(|cx| {
cx.spawn_in(window, async move |this, cx| {
let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
@@ -5659,16 +5552,21 @@ impl GitPanelMessageTooltip {
)
})?;
let details = details.await?;
let provider_registry = cx
.update(|_, app| GitHostingProviderRegistry::default_global(app))
.ok();
let commit_details = crate::commit_tooltip::CommitDetails {
sha: details.sha.clone(),
author_name: details.author_name.clone(),
author_email: details.author_email.clone(),
commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
message: Some(ParsedCommitMessage {
message: details.message,
..Default::default()
}),
message: Some(ParsedCommitMessage::parse(
details.sha.to_string(),
details.message.to_string(),
remote_url.as_deref(),
provider_registry,
)),
};
this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {

View File

@@ -10,6 +10,7 @@ use ui::{
};
mod blame_ui;
pub mod clone;
use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},

View File

@@ -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

View File

@@ -182,6 +182,11 @@ impl LineWrapper {
// Cyrillic for Russian, Ukrainian, etc.
// https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
matches!(c, '\u{0400}'..='\u{04FF}') ||
// Vietnamese (https://vietunicode.sourceforge.net/charset/)
matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
// Some other known special characters that should be treated as word characters,
// e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`,
// `2^3`, `a~b`, `a=1`, `Self::new`, etc.
@@ -618,7 +623,12 @@ mod tests {
#[track_caller]
fn assert_word(word: &str) {
for c in word.chars() {
assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
assert!(
LineWrapper::is_word_char(c),
"assertion failed for '{}' (unicode 0x{:x})",
c,
c as u32
);
}
}
@@ -661,6 +671,8 @@ mod tests {
assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
// Cyrillic
assert_word("АБВГДЕЖЗИЙКЛМНОП");
// Vietnamese (https://github.com/zed-industries/zed/issues/23245)
assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
// non-word characters
assert_not_word("你好");

View File

@@ -5948,6 +5948,11 @@ impl Repository {
self.pending_ops.edit(edits, ());
ids
}
pub fn default_remote_url(&self) -> Option<String> {
self.remote_upstream_url
.clone()
.or(self.remote_origin_url.clone())
}
}
fn get_permalink_in_rust_registry_src(

View File

@@ -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>) {

View File

@@ -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);
}

Binary file not shown.

View File

@@ -15,11 +15,13 @@ use extension::ExtensionHostProxy;
use fs::{Fs, RealFs};
use futures::{StreamExt, channel::oneshot, future};
use git::GitHostingProviderRegistry;
use git_ui::clone::clone_and_open;
use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
use gpui_tokio::Tokio;
use language::LanguageRegistry;
use onboarding::{FIRST_OPEN, show_onboarding_view};
use project_panel::ProjectPanel;
use prompt_store::PromptBuilder;
use remote::RemoteConnectionOptions;
use reqwest_client::ReqwestClient;
@@ -33,10 +35,12 @@ use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
use session::{AppSession, Session};
use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
use std::{
cell::RefCell,
env,
io::{self, IsTerminal},
path::{Path, PathBuf},
process,
rc::Rc,
sync::{Arc, OnceLock},
time::Instant,
};
@@ -893,6 +897,41 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
})
.detach_and_log_err(cx);
}
OpenRequestKind::GitClone { repo_url } => {
workspace::with_active_or_new_workspace(cx, |_workspace, window, cx| {
if window.is_window_active() {
clone_and_open(
repo_url,
cx.weak_entity(),
window,
cx,
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
workspace.focus_panel::<ProjectPanel>(window, cx);
}),
);
return;
}
let subscription = Rc::new(RefCell::new(None));
subscription.replace(Some(cx.observe_in(&cx.entity(), window, {
let subscription = subscription.clone();
let repo_url = repo_url.clone();
move |_, workspace_entity, window, cx| {
if window.is_window_active() && subscription.take().is_some() {
clone_and_open(
repo_url.clone(),
workspace_entity.downgrade(),
window,
cx,
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
workspace.focus_panel::<ProjectPanel>(window, cx);
}),
);
}
}
})));
});
}
OpenRequestKind::GitCommit { sha } => {
cx.spawn(async move |cx| {
let paths_with_position =

View File

@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use ui::SharedString;
use util::ResultExt;
use util::paths::PathWithPosition;
use workspace::PathList;
@@ -58,6 +59,9 @@ pub enum OpenRequestKind {
/// `None` opens settings without navigating to a specific path.
setting_path: Option<String>,
},
GitClone {
repo_url: SharedString,
},
GitCommit {
sha: String,
},
@@ -113,6 +117,8 @@ impl OpenRequest {
this.kind = Some(OpenRequestKind::Setting {
setting_path: Some(setting_path.to_string()),
});
} else if let Some(clone_path) = url.strip_prefix("zed://git/clone") {
this.parse_git_clone_url(clone_path)?
} else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") {
this.parse_git_commit_url(commit_path)?
} else if url.starts_with("ssh://") {
@@ -143,6 +149,26 @@ impl OpenRequest {
}
}
fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
// Format: /?repo=<url> or ?repo=<url>
let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path);
let query = clone_path
.strip_prefix('?')
.context("invalid git clone url: missing query string")?;
let repo_url = url::form_urlencoded::parse(query.as_bytes())
.find_map(|(key, value)| (key == "repo").then_some(value))
.filter(|s| !s.is_empty())
.context("invalid git clone url: missing repo query parameter")?
.to_string()
.into();
self.kind = Some(OpenRequestKind::GitClone { repo_url });
Ok(())
}
fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> {
// Format: <sha>?repo=<path>
let (sha, query) = commit_path
@@ -1087,4 +1113,80 @@ mod tests {
assert!(!errored_reuse);
}
#[gpui::test]
fn test_parse_git_clone_url(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let request = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec![
"zed://git/clone/?repo=https://github.com/zed-industries/zed.git".into(),
],
..Default::default()
},
cx,
)
.unwrap()
});
match request.kind {
Some(OpenRequestKind::GitClone { repo_url }) => {
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
}
_ => panic!("Expected GitClone kind"),
}
}
#[gpui::test]
fn test_parse_git_clone_url_without_slash(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let request = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec![
"zed://git/clone?repo=https://github.com/zed-industries/zed.git".into(),
],
..Default::default()
},
cx,
)
.unwrap()
});
match request.kind {
Some(OpenRequestKind::GitClone { repo_url }) => {
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
}
_ => panic!("Expected GitClone kind"),
}
}
#[gpui::test]
fn test_parse_git_clone_url_with_encoding(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let request = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec![
"zed://git/clone/?repo=https%3A%2F%2Fgithub.com%2Fzed-industries%2Fzed.git"
.into(),
],
..Default::default()
},
cx,
)
.unwrap()
});
match request.kind {
Some(OpenRequestKind::GitClone { repo_url }) => {
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
}
_ => panic!("Expected GitClone kind"),
}
}
}

View File

@@ -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

View 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}"

View File

@@ -109,19 +109,6 @@ fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJo
}
fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob {
fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"create-github-app-token",
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
)
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
.id("get-app-token");
let output = StepOutput::new(&step, "token");
(step, output)
}
fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step<Run> {
named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token))
}
@@ -148,7 +135,7 @@ fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob
.add_env(("GITHUB_TOKEN", token))
}
let (authenticate, token) = authenticate_as_zippy();
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
Job::default()

View File

@@ -3,7 +3,7 @@ use gh_workflow::*;
use crate::tasks::workflows::{
runners,
steps::{self, NamedJob, named},
vars::{self, StepOutput, WorkflowInput},
vars::{StepOutput, WorkflowInput},
};
pub fn cherry_pick() -> Workflow {
@@ -29,19 +29,6 @@ fn run_cherry_pick(
commit: &WorkflowInput,
channel: &WorkflowInput,
) -> NamedJob {
fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"create-github-app-token",
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
) // v2
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
.id("get-app-token");
let output = StepOutput::new(&step, "token");
(step, output)
}
fn cherry_pick(
branch: &WorkflowInput,
commit: &WorkflowInput,
@@ -54,7 +41,7 @@ fn run_cherry_pick(
.add_env(("GITHUB_TOKEN", token))
}
let (authenticate, token) = authenticate_as_zippy();
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
Job::default()

View File

@@ -97,17 +97,20 @@ pub(crate) fn create_sentry_release() -> Step<Use> {
}
fn auto_release_preview(deps: &[&NamedJob; 1]) -> NamedJob {
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
dependant_job(deps)
.runs_on(runners::LINUX_SMALL)
.cond(Expression::new(indoc::indoc!(
r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
)))
.add_step(authenticate)
.add_step(
steps::script(
r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
)
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
.add_env(("GITHUB_TOKEN", &token)),
)
)
}

View File

@@ -354,3 +354,16 @@ pub fn trigger_autofix(run_clippy: bool) -> Step<Run> {
))
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
}
pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"create-github-app-token",
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
)
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
.id("get-app-token");
let output = StepOutput::new(&step, "token");
(step, output)
}