Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/specs/DESIGN-SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,9 @@ Page-level grids and marketing card styles must not leak into `.prose`. Do not r

Screenshots and video of a SharpLsp feature show the real extension, never a mock-up.

- **Capture.** The profiler media is shot by `src/website/tools/profiler-film/` (its README says how). Every shot runs on `kit/set.mjs`'s `onSet`: an isolated VS Code (its own user-data and extensions directories, never the user's editor) running the packaged VSIX against a real .NET demo app from `demos/`. A walkthrough is one file in `films/`, a list of scenes that `film.mjs` shoots as real-time clips and publishes; `kit/acts/` tells the CPU and memory stories, so each film supplies only its words, its demo and its fix. `films/leak.mjs` films the C# Storefront demo and `films/fsharp.mjs` its F# twin, FsStorefront, so every F# claim the docs make about the profiler is shown on F# code. The composited one-minute film is `films/profiler/`: `capture.mjs` records stills and screencasts, and `render.mjs` turns them into the site's assets.
- **Capture.** Product films are shot by `src/website/tools/profiler-film/` (its README says how). Every shot runs on `kit/set.mjs`'s `onSet`: an isolated VS Code (its own user-data and extensions directories, never the user's editor) running the packaged VSIX against a real .NET demo from `demos/`. A walkthrough is one file in `films/`, a list of scenes that `film.mjs` shoots as real-time clips and publishes; `kit/acts/` tells the shared stories (the CPU and memory loops, the language features, the Solution Explorer), so each film supplies only its words, its demo and its edits. The profiler films run on the C# Storefront demo and its F# twin, FsStorefront; the language films run on `Shop.slnx`, both in one solution, and show F# before C#. Every F# claim the docs make is shown on F# code. The composited one-minute film is `films/profiler/`: `capture.mjs` records stills and screencasts, and `render.mjs` turns them into the site's assets.
- **Honesty.** Footage plays in real time unless the frame says otherwise: every fast-forwarded stretch carries an on-screen speed marker, and captions name time-lapses. Motion graphics (title cards, camera moves, highlights, the cursor) frame the footage but never alter what the editor shows.
- **Privacy.** No frame may show a user name, home directory, hostname or terminal. The demo app runs from a neutral temporary path and the terminal is never opened.
- **Privacy.** No frame may show a user name, home directory or hostname. The demo runs from a neutral temporary path, and the editor runs on a copy-on-write clone of the .NET install under that path, with its login-shell environment off, so every path it prints is neutral. A terminal appears only where the feature runs in one (F# Interactive, a build task, a contributor's command line), and a shell terminal starts with no profile and a bare `$ ` prompt.
- **Formats.**
- Films ship as H.264 MP4 and VP9 WebM with a WebP poster.
- Stills ship as WebP, 2400px wide, with `width` and `height` set.
Expand All @@ -137,7 +137,7 @@ Screenshots and video of a SharpLsp feature show the real extension, never a moc
- Every video ships a WebP poster, the films and the recordings alike.
- **Narration.** A walkthrough may be narrated (`kit/sound/`). Its reviewed script (`films/<film>.narration.json`) is spoken by Kokoro-82M through `kokoro-js` on the capturing machine, never by a speech service or the operating system's voices, whose licences forbid publishing. Each line starts just after its scene's cut, and a scene holds its last frame until its line ends. The music is an original score synthesised from code (`kit/sound/score.mjs`), so no film carries licensed or sampled music, and it ducks under the voice. A narrated film ships AAC in its MP4 and Opus in its WebM, plays unmuted with controls, and carries WebVTT captions in every site language (`<id>.<lang>.vtt`, the page's own language shown by default), listed under `captions` in the catalogue.
- **One catalogue.** Every video on the site is declared once in `src/website/src/_data/videos.json`: encodings, size, length, upload date, and its label and caption in each language. Pages place a video with the `{% video "<id>" %}` shortcode (`src/website/video-figure.js`) and never repeat `<video>` markup. The shortcode renders in the page's language and emits a schema.org `VideoObject` whose name is the player's label, so search results describe exactly what a reader hears.
- **Placement.** A video goes wherever a page talks about what it shows: the home page (the profiler film autoplaying in the hero, the CPU recording in the profiler section), the Getting Started page (the tour), the profiler docs (the film and both recordings), the F# profiling docs and the F# language page (the F# walkthrough), and every blog post that discusses the profiler or the editor experience, in every language the page is published in.
- **Placement.** A video goes wherever a page talks about what it shows: the home page (the profiler film autoplaying in the hero, the CPU recording in the profiler section), every docs page with a feature to show (its own narrated walkthrough, after the page's introduction; the profiler docs add both recordings, the F# language page the F# profiling walkthrough too), and every blog post that discusses the profiler or the editor experience, in every language the page is published in. The Zed page has no film: Zed is a native application, and filming it would mean recording the real desktop.

## [WEB-DESIGN-RESPONSIVE] Responsive Behavior

Expand Down
5 changes: 4 additions & 1 deletion src/editors/vscode/src/nuget-browser/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,13 @@ function buildDetailsHtml(state: RenderState): string {
? `<div class="section"><h4 class="section-title">Tags</h4><div class="tags">${pkg.tags.map((t) => `<span class="tag">${esc(t.toUpperCase())}</span>`).join('')}</div></div>`
: '';

// The picker shows the installed version, else the one Install sends: never
// simply the newest listed, which is often a prerelease.
const shownVersion = pkg.installedVersion ?? pkg.version;
const versionOptions = versions
.map(
(v) =>
`<option value="${escAttr(v)}" ${v === pkg.installedVersion ? 'selected' : ''}>${esc(v)}</option>`,
`<option value="${escAttr(v)}" ${v === shownVersion ? 'selected' : ''}>${esc(v)}</option>`,
)
.join('');

Expand Down
27 changes: 27 additions & 0 deletions src/editors/vscode/src/test/suite/nuget-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,33 @@ suite('NuGet Browser — pure html', () => {
assert.ok(html.includes('<option value="13.0.3"'), 'version option rendered');
});

test('buildHtml details panel shows the version Install installs, not the newest prerelease', () => {
// nuget.org lists versions newest first, prereleases included; search
// answers with the latest stable one, which is what Install sends.
const pkg = searchResult({
version: '5.2.0',
_versions: ['6.0.0-beta003', '6.0.0-beta002', '5.2.0', '5.1.0'],
});
const html = buildHtml(baseState({ selectedPackage: pkg }));
assert.ok(
html.includes(`installPackage('Newtonsoft.Json', '5.2.0')`),
'Install sends the stable version',
);
assert.ok(
html.includes('<option value="5.2.0" selected>'),
'the picker shows the version Install sends',
);
assert.ok(
!html.includes('<option value="6.0.0-beta003" selected>'),
'a prerelease is never shown as the one to install',
);
assert.strictEqual(
html.match(/ selected>/g)?.length,
1,
'exactly one version is shown as chosen',
);
});

test('buildHtml details panel renders Remove button for installed pkg', () => {
const pkg = searchResult();
const html = buildHtml(
Expand Down
128 changes: 78 additions & 50 deletions src/sharplsp/src/sort_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,76 +454,104 @@ fn build_edits(
source: &str,
config: &SortConfig,
) -> Vec<TextEdit> {
let source_bytes = source.as_bytes();

// Find the first and last member to get the replacement range.
// Use trivia_byte for the first member so the edit covers leading comments.
// The replacement starts at the first member's leading trivia, so the edit
// covers its comments, and ends where the last member ends.
let first = members.iter().min_by_key(|m| m.trivia_byte);
let last = members.iter().max_by_key(|m| m.end_byte);

let (Some(first_member), Some(last_member)) = (first, last) else {
return vec![];
};
let trivia_pos = byte_to_position(source.as_bytes(), first_member.trivia_byte);

// Compute the edit start position from the trivia byte offset.
let trivia_pos = byte_to_position(source_bytes, first_member.trivia_byte);
vec![TextEdit {
range: EditRange {
start: EditPosition {
line: trivia_pos.0,
character: trivia_pos.1,
},
end: EditPosition {
line: last_member.end_row,
character: last_member.end_col,
},
},
new_text: reordered_text(members, sorted_indices, source, config),
}]
}

// Build the reordered text by extracting each member's full text
// (including its leading trivia) in sorted order.
// Insert blank lines between different accessibility/category groups.
let mut new_text = String::new();
let mut prev_member: Option<&MemberInfo> = None;
/// The members in sorted order, each on its own line. A type whose members are
/// spaced by blank lines keeps one between every member; a compact type gets one
/// only where a new accessibility/category group starts. A member's own leading
/// blank lines are replaced, never added to, so no gap grows past one line.
fn reordered_text(
members: &[MemberInfo],
sorted_indices: &[usize],
source: &str,
config: &SortConfig,
) -> String {
let newline = if source.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let separator = members.iter().find_map(|member| {
source_bytes
source
.as_bytes()
.get(member.end_byte)
.copied()
.filter(|byte| *byte == b',')
.map(char::from)
});
let ordered = sorted_indices
.iter()
.filter_map(|&index| members.iter().find(|m| m.index == index));
let spaced = members.iter().any(|member| {
source
.get(member.trivia_byte..member.end_byte)
.is_some_and(|text| leading_layout(text).blank)
});

for (sorted_pos, &original_index) in sorted_indices.iter().enumerate() {
let member = members.iter().find(|m| m.index == original_index);
let Some(member) = member else { continue };

// Extract the full member text including leading trivia.
let Some(text) = source_bytes.get(member.trivia_byte..member.end_byte) else {
let mut new_text = String::new();
let mut prev_member: Option<&MemberInfo> = None;
for (sorted_pos, member) in ordered.enumerate() {
let Some(text) = source.get(member.trivia_byte..member.end_byte) else {
continue;
};
let member_text = String::from_utf8_lossy(text);

// Insert blank line between groups.
if let Some(prev) = prev_member {
if groups_differ(prev, member, config) && !new_text.ends_with("\n\n") {
if !new_text.ends_with('\n') {
new_text.push('\n');
}
new_text.push('\n');
}
}

new_text.push_str(&member_text);
let layout = leading_layout(text);
let gap = prev_member.is_some_and(|prev| spaced || groups_differ(prev, member, config));
let breaks = match (layout.on_own_line, gap) {
(false, _) => 0,
(true, false) => 1,
(true, true) => 2,
};
new_text.push_str(&newline.repeat(breaks));
new_text.push_str(layout.body);
append_separator(&mut new_text, separator, sorted_pos, sorted_indices.len());

if sorted_pos < sorted_indices.len() - 1 && !member_text.ends_with('\n') {
new_text.push('\n');
}

prev_member = Some(member);
}
new_text
}

vec![TextEdit {
range: EditRange {
start: EditPosition {
line: trivia_pos.0,
character: trivia_pos.1,
},
end: EditPosition {
line: last_member.end_row,
character: last_member.end_col,
},
},
new_text,
}]
/// A member's text, split at the line its content starts on.
struct LeadingLayout<'a> {
/// The member starts on a line of its own.
on_own_line: bool,
/// A blank line came before the member.
blank: bool,
/// The member from its first line's indentation on (or as it was, mid-line).
body: &'a str,
}

/// Split `text` (a member with its leading trivia) into its leading line breaks and its body.
fn leading_layout(text: &str) -> LeadingLayout<'_> {
let content = text.len() - text.trim_start().len();
let leading = text.get(..content).unwrap_or_default();
let line_start = leading.rfind('\n').map_or(0, |newline| newline + 1);
let breaks = leading.matches('\n').count();
LeadingLayout {
on_own_line: breaks > 0,
blank: breaks > 1,
body: text.get(line_start..).unwrap_or(text),
}
}

/// Add punctuation that belongs between declarations rather than to either AST node.
Expand Down
35 changes: 35 additions & 0 deletions src/sharplsp/tests/e2e_modules/sort_members_extra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,38 @@ fn test_sort_members_preserves_region_blocks() {
assert!(new_text.contains("#region"), "#region preserved");
assert!(new_text.contains("#endregion"), "#endregion preserved");
}

// 62. SORT MEMBERS: ONE BLANK LINE BETWEEN MEMBERS, NEVER MORE

#[test]
fn test_sort_members_keeps_one_blank_line_between_members() {
// Members separated by one blank line, as most C# is written. Each member's
// leading blank line must travel with it once, not pile up beside the
// separators the sort adds between groups.
let source = "namespace Test\n{\n public class Pipeline\n {\n private static int Zeta() => 1;\n\n public void Run() { }\n\n private int Alpha() => 2;\n }\n}\n";
let new_text = sort_members_new_text(source, (2, 4, 9, 5));

assert!(
!new_text.contains("\n\n\n"),
"sorting must never leave two blank lines in a row: {new_text:?}"
);
assert_eq!(
new_text,
"\n public void Run() { }\n\n private int Alpha() => 2;\n\n private static int Zeta() => 1;",
"public first, then the private methods alphabetically, one blank line apart"
);
}

// 63. SORT MEMBERS: COMPACT MEMBERS STAY COMPACT

#[test]
fn test_sort_members_keeps_compact_members_compact() {
// Members written without blank lines between them gain none within a group.
let source = "namespace Test\n{\n public class Ids\n {\n public int Zed;\n public int Bee;\n public int Ant;\n }\n}\n";
let new_text = sort_members_new_text(source, (2, 4, 7, 5));

assert_eq!(
new_text, "\n public int Ant;\n public int Bee;\n public int Zed;",
"one group, written compactly, stays compact"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,36 @@ let ``renaming a binding inside a loaded script rewrites its uses`` () = task {
finally
cleanup dir
}

// ── Definition into a file the editor never opened ──────────────────

/// A freshly loaded project, one file open: Go to Definition on a module-qualified
/// call lands in the file that declares it, which the editor has not opened. The
/// editor sends the open file's text first, then asks (#387).
[<Fact>]
let ``definition of a module-qualified call lands in the declaring file the editor never opened`` () = task {
let catalog = "module Shop.Catalog\n\nlet generate count = List.init count string\n"
let call = "let catalog = Catalog.generate 3"
let program = $"module Shop.Program\n\n{call}\n"
let! (state, dir, _fsproj, paths) = loadWorkspace [ "Catalog.fs", catalog; "Program.fs", program ]

try
FSharpWorkspace.applyDidChange state paths[1] program
let character = call.IndexOf("generate", StringComparison.Ordinal) + 2
// The editor asks for CodeLens on the open file first: a project-wide check.
let! _ = FSharpCodeLens.getCodeLenses state paths[1]

let! hover = FSharpWorkspace.getHover state paths[1] 2 character
let! definition = FSharpWorkspace.getDefinition state paths[1] 2 character

Assert.True(hover.IsSome, "hover answers on the call")

match definition with
| Some location ->
Assert.Equal(NativePaths.NameOf paths[0], NativePaths.NameOf location.FilePath)
Assert.Equal(2, location.Line)
Assert.Equal(4, location.Character)
| None -> failwith "Go to Definition found nothing for Catalog.generate"
finally
cleanup dir
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,27 @@ let ``a lowercase short output flag also suppresses the generated identity`` ()
Assert.DoesNotContain("--out:CrackedC.dll", options.OtherOptions)
finally
cleanup dir

[<Fact>]
let ``hand-built options name each source once, so FCS never sees a file twice`` () =
let dir = NativePaths.Temp($"sharplsp-crack-{Guid.NewGuid():N}")

try
let fsproj = writeProject dir "CrackedD" "--nowarn:52" [||]
let state = FSharpWorkspace.create ()
let options = FSharpWorkspace.buildProjectOptions state fsproj
let library = NativePaths.Resolve(dir, "Library.fs")

// Sources belong in SourceFiles alone: FCS compiles every bare argument it
// finds in OtherOptions as a source too.
Assert.Equal<string array>([| library |], options.SourceFiles)
Assert.DoesNotContain(library, options.OtherOptions)
Assert.All(options.OtherOptions, fun option -> Assert.StartsWith("-", option))

let checkedProject = state.Checker.ParseAndCheckProject(options) |> Async.RunSynchronously

// FS3551: "The source file ... already appeared in the compilation list".
Assert.DoesNotContain(checkedProject.Diagnostics, fun diagnostic -> diagnostic.ErrorNumber = 3551)
Assert.Empty(checkedProject.Diagnostics)
finally
cleanup dir
12 changes: 5 additions & 7 deletions src/sidecars/SharpLsp.Sidecar.FSharp/FSharpProjectLoading.fs
Original file line number Diff line number Diff line change
Expand Up @@ -174,17 +174,15 @@ let private compilerArgs fsprojPath projectFlags =
projectIdentityArgs fsprojPath projectFlags
projectFlags ]

/// Build persistent FCS options with explicit source files and all references.
/// Build persistent FCS options with explicit source files and all references. The
/// sources go in SourceFiles alone: FCS also compiles every bare argument of the command
/// line, so a source on both would be compiled twice (FS3551).
let buildProjectOptions (checker: FSharpChecker) (fsprojPath: string) : FSharpProjectOptions =
let sourceFiles = parseFsprojSourceFiles fsprojPath
let projectFlags = parseFsprojOtherFlags fsprojPath
let otherOptions = compilerArgs fsprojPath projectFlags

let options =
checker.GetProjectOptionsFromCommandLineArgs(fsprojPath, Array.append otherOptions sourceFiles)
checker.GetProjectOptionsFromCommandLineArgs(fsprojPath, compilerArgs fsprojPath (parseFsprojOtherFlags fsprojPath))

{ options with
SourceFiles = sourceFiles }
SourceFiles = parseFsprojSourceFiles fsprojPath }

let scriptOptions (checker: FSharpChecker) (scriptPath: string) (source: string) (ct: CancellationToken) =
let flags = [| "--define:INTERACTIVE"; "--define:EDITING" |]
Expand Down
Loading
Loading